mosscode 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.
- mosscode/__init__.py +10 -0
- mosscode/__main__.py +3 -0
- mosscode/agent.py +486 -0
- mosscode/agents/__init__.py +249 -0
- mosscode/agents/builtin/explore.md +16 -0
- mosscode/agents/builtin/general.md +12 -0
- mosscode/agents/builtin/plan.md +16 -0
- mosscode/agents/runtime.py +685 -0
- mosscode/agents/tasks.py +193 -0
- mosscode/appearance.py +184 -0
- mosscode/builtin_commands.py +1363 -0
- mosscode/cli.py +547 -0
- mosscode/commands.py +254 -0
- mosscode/compaction.py +259 -0
- mosscode/config.py +337 -0
- mosscode/context.py +102 -0
- mosscode/conversation.py +1046 -0
- mosscode/coordinator.py +54 -0
- mosscode/doctor.py +303 -0
- mosscode/hooks/__init__.py +22 -0
- mosscode/hooks/actions.py +151 -0
- mosscode/hooks/conditions.py +170 -0
- mosscode/hooks/config.py +404 -0
- mosscode/hooks/engine.py +401 -0
- mosscode/hooks/events.py +48 -0
- mosscode/launcher.py +177 -0
- mosscode/mcp_client.py +460 -0
- mosscode/mcp_config.py +216 -0
- mosscode/member_ui.py +289 -0
- mosscode/memory.py +291 -0
- mosscode/memory_extract.py +245 -0
- mosscode/messages.py +216 -0
- mosscode/paths.py +31 -0
- mosscode/permissions.py +733 -0
- mosscode/prompts.py +483 -0
- mosscode/providers.py +587 -0
- mosscode/scrollback.py +106 -0
- mosscode/security.py +282 -0
- mosscode/serialization.py +121 -0
- mosscode/session.py +563 -0
- mosscode/setup.py +248 -0
- mosscode/skills/__init__.py +455 -0
- mosscode/skills/builtin/commit/SKILL.md +15 -0
- mosscode/skills/builtin/review/SKILL.md +15 -0
- mosscode/skills/builtin/test/SKILL.md +13 -0
- mosscode/skills/builtin/test/scripts/detect.py +96 -0
- mosscode/skills/builtin/test/tool.json +9 -0
- mosscode/teams/__init__.py +216 -0
- mosscode/teams/backends.py +259 -0
- mosscode/teams/board.py +230 -0
- mosscode/teams/lock.py +173 -0
- mosscode/teams/mailbox.py +172 -0
- mosscode/teams/member.py +427 -0
- mosscode/teams/merge.py +207 -0
- mosscode/teams/pane.py +20 -0
- mosscode/teams/pane_app.py +167 -0
- mosscode/teams/process.py +371 -0
- mosscode/teams/runtime.py +1355 -0
- mosscode/teams/view.py +182 -0
- mosscode/tool_budget.py +86 -0
- mosscode/tools/__init__.py +38 -0
- mosscode/tools/agent.py +127 -0
- mosscode/tools/base.py +384 -0
- mosscode/tools/diffing.py +16 -0
- mosscode/tools/execution.py +99 -0
- mosscode/tools/files.py +287 -0
- mosscode/tools/memory.py +65 -0
- mosscode/tools/plan.py +30 -0
- mosscode/tools/process.py +216 -0
- mosscode/tools/search.py +229 -0
- mosscode/tools/search_worker.py +243 -0
- mosscode/tools/shell.py +121 -0
- mosscode/tools/skill.py +118 -0
- mosscode/tools/team.py +447 -0
- mosscode/tools/windows_job.py +75 -0
- mosscode/tui.py +2325 -0
- mosscode/usage.py +68 -0
- mosscode/windows_inline.py +148 -0
- mosscode/worktree/__init__.py +29 -0
- mosscode/worktree/changes.py +59 -0
- mosscode/worktree/git.py +125 -0
- mosscode/worktree/manager.py +462 -0
- mosscode/worktree/meta.py +82 -0
- mosscode/worktree/names.py +52 -0
- mosscode/worktree/setup.py +119 -0
- mosscode-0.1.0.dist-info/METADATA +92 -0
- mosscode-0.1.0.dist-info/RECORD +91 -0
- mosscode-0.1.0.dist-info/WHEEL +5 -0
- mosscode-0.1.0.dist-info/entry_points.txt +3 -0
- mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
- mosscode-0.1.0.dist-info/top_level.txt +1 -0
mosscode/session.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""Session storage: the directory of stored tool results and compaction records,
|
|
2
|
+
and the JSONL transcript that lets a session be resumed."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import shutil
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from dataclasses import dataclass, replace
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
from importlib.metadata import version
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .messages import Message, ToolResult
|
|
17
|
+
from .paths import SESSION_ID, mosscode_home, new_session_id, project_dir
|
|
18
|
+
from .serialization import message_from_json, message_to_json
|
|
19
|
+
|
|
20
|
+
MARKER = ".mosscode-session"
|
|
21
|
+
KEEP_DAYS = 30
|
|
22
|
+
TITLE_BYTES = 64 * 1024
|
|
23
|
+
TITLE_CHARS = 40
|
|
24
|
+
MESSAGE_PREFIX = b'{"type":"message"'
|
|
25
|
+
INTERRUPTED = "会话中断,这个调用可能未执行,也可能执行了但结果丢失;需要时请检查当前状态后重新执行。"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sessions_root() -> Path:
|
|
29
|
+
"""The old location of tool results; no longer written, only pruned until it empties."""
|
|
30
|
+
return Path.home() / ".mosscode" / "sessions"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def project_sessions(root: Path, home: Path | None = None) -> Path:
|
|
34
|
+
return project_dir(root, home) / "sessions"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Session:
|
|
38
|
+
def __init__(self, directory: Path, *, id: str | None = None, transcript: Path | None = None):
|
|
39
|
+
self.directory = directory.resolve()
|
|
40
|
+
self.id = id or self.directory.name
|
|
41
|
+
# <id>.jsonl beside the directory; None for sessions that keep no transcript.
|
|
42
|
+
self.transcript_path = transcript
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def create(cls, base: Path | None = None, now: datetime | None = None, *, lazy: bool = False) -> "Session":
|
|
46
|
+
"""lazy=True defers the directory to the first write, so short runs leave nothing behind."""
|
|
47
|
+
base = sessions_root() if base is None else base
|
|
48
|
+
stamp = (now or datetime.now()).strftime("%Y%m%d-%H%M%S")
|
|
49
|
+
session = cls(base / f"{stamp}-{secrets.token_hex(2)}")
|
|
50
|
+
if not lazy:
|
|
51
|
+
session.ensure()
|
|
52
|
+
return session
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def new(cls, root: Path, *, home: Path | None = None, now: datetime | None = None) -> "Session":
|
|
56
|
+
"""A fresh ID for this project; nothing is created until the first write."""
|
|
57
|
+
base = project_sessions(root, home)
|
|
58
|
+
while True:
|
|
59
|
+
id = new_session_id(now)
|
|
60
|
+
if not any(p.exists() for p in (base / id, base / f"{id}.jsonl", base / f"{id}.lock")):
|
|
61
|
+
return cls(base / id, id=id, transcript=base / f"{id}.jsonl")
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_transcript(cls, path: Path) -> "Session":
|
|
65
|
+
return cls(path.with_suffix(""), id=path.stem, transcript=path)
|
|
66
|
+
|
|
67
|
+
def ensure(self) -> None:
|
|
68
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
marker = self.directory / MARKER
|
|
70
|
+
if not marker.exists():
|
|
71
|
+
marker.write_text("Created by MossCode; safe to delete.\n", encoding="utf-8")
|
|
72
|
+
|
|
73
|
+
def persist_result(self, call_id: str, text: str) -> Path:
|
|
74
|
+
"""Write once per call ID; a later attempt keeps the first file."""
|
|
75
|
+
self.ensure()
|
|
76
|
+
name = re.sub(r"[^A-Za-z0-9_.-]", "_", call_id)[:100].strip(".") or secrets.token_hex(4)
|
|
77
|
+
path = self.directory / "tool-results" / f"{name}.txt"
|
|
78
|
+
path.parent.mkdir(exist_ok=True)
|
|
79
|
+
try:
|
|
80
|
+
with path.open("x", encoding="utf-8", newline="") as stream:
|
|
81
|
+
stream.write(text)
|
|
82
|
+
except FileExistsError:
|
|
83
|
+
pass
|
|
84
|
+
return path
|
|
85
|
+
|
|
86
|
+
def write_record(self, text: str) -> Path:
|
|
87
|
+
"""compact-<n>.md with the next free n; earlier records are never overwritten."""
|
|
88
|
+
self.ensure()
|
|
89
|
+
number = 1
|
|
90
|
+
while True:
|
|
91
|
+
path = self.directory / f"compact-{number}.md"
|
|
92
|
+
try:
|
|
93
|
+
with path.open("x", encoding="utf-8", newline="") as stream:
|
|
94
|
+
stream.write(text)
|
|
95
|
+
return path
|
|
96
|
+
except FileExistsError:
|
|
97
|
+
number += 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Transcript records -------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _now() -> str:
|
|
104
|
+
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def message_record(message: Message) -> dict:
|
|
108
|
+
return {"type": "message", "time": _now(), "message": message_to_json(message)}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def compact_record(boundary: Message, keep: int, record: str | None) -> dict:
|
|
112
|
+
"""The history became the boundary plus the last `keep` messages."""
|
|
113
|
+
return {"type": "compact", "time": _now(), "boundary": message_to_json(boundary), "keep": keep, "record": record}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def skill_record(name: str, arguments: str, pinned: bool) -> dict:
|
|
117
|
+
"""A skill was used; pinned when its SOP went into <active-skills>."""
|
|
118
|
+
return {"type": "skill", "time": _now(), "name": name, "arguments": arguments, "pinned": pinned}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def provider_record(name: str, protocol: str, model: str) -> dict:
|
|
122
|
+
"""Earlier messages lose their native data, as in a /model switch."""
|
|
123
|
+
return {"type": "provider", "time": _now(), "name": name, "protocol": protocol, "model": model}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Locks --------------------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class SessionLocked(Exception):
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _lock(stream) -> None:
|
|
134
|
+
if os.name == "nt":
|
|
135
|
+
import msvcrt
|
|
136
|
+
|
|
137
|
+
stream.seek(0)
|
|
138
|
+
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
|
|
139
|
+
else:
|
|
140
|
+
import fcntl
|
|
141
|
+
|
|
142
|
+
fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Lock:
|
|
146
|
+
"""An OS lock on <id>.lock: released by the system when the process exits or crashes."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, path: Path):
|
|
149
|
+
self.path = path
|
|
150
|
+
self._stream = None
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def held(self) -> bool:
|
|
154
|
+
return self._stream is not None
|
|
155
|
+
|
|
156
|
+
def acquire(self) -> None:
|
|
157
|
+
if self._stream is not None:
|
|
158
|
+
return
|
|
159
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
stream = self.path.open("a+b")
|
|
161
|
+
try:
|
|
162
|
+
_lock(stream)
|
|
163
|
+
except OSError:
|
|
164
|
+
stream.close()
|
|
165
|
+
raise SessionLocked("该会话正在另一个 MossCode 中使用。") from None
|
|
166
|
+
self._stream = stream
|
|
167
|
+
|
|
168
|
+
def release(self) -> None:
|
|
169
|
+
stream, self._stream = self._stream, None
|
|
170
|
+
if stream is None:
|
|
171
|
+
return
|
|
172
|
+
if os.name == "nt":
|
|
173
|
+
import msvcrt
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
stream.seek(0)
|
|
177
|
+
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
|
|
178
|
+
except OSError:
|
|
179
|
+
pass
|
|
180
|
+
stream.close() # Also releases the lock; a crash releases it the same way.
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def is_locked(lock_path: Path) -> bool:
|
|
184
|
+
if not lock_path.exists():
|
|
185
|
+
return False
|
|
186
|
+
probe = Lock(lock_path)
|
|
187
|
+
try:
|
|
188
|
+
probe.acquire()
|
|
189
|
+
except (SessionLocked, OSError):
|
|
190
|
+
return True
|
|
191
|
+
probe.release()
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Transcript ---------------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _repair_tail(path: Path) -> None:
|
|
199
|
+
"""Cut a half-written last line so the next record starts on its own line."""
|
|
200
|
+
with path.open("r+b") as stream:
|
|
201
|
+
size = stream.seek(0, os.SEEK_END)
|
|
202
|
+
if size == 0:
|
|
203
|
+
return
|
|
204
|
+
stream.seek(size - 1)
|
|
205
|
+
if stream.read(1) == b"\n":
|
|
206
|
+
return
|
|
207
|
+
position = size
|
|
208
|
+
while position > 0:
|
|
209
|
+
start = max(0, position - 65536)
|
|
210
|
+
stream.seek(start)
|
|
211
|
+
chunk = stream.read(position - start)
|
|
212
|
+
index = chunk.rfind(b"\n")
|
|
213
|
+
if index >= 0:
|
|
214
|
+
stream.truncate(start + index + 1)
|
|
215
|
+
return
|
|
216
|
+
position = start
|
|
217
|
+
stream.truncate(0)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class Transcript:
|
|
221
|
+
"""Append-only JSONL: one complete line per write; the first failure stops writing for this session."""
|
|
222
|
+
|
|
223
|
+
def __init__(self, session: Session, cwd: Path):
|
|
224
|
+
self.session, self.cwd = session, cwd
|
|
225
|
+
self.path = session.transcript_path
|
|
226
|
+
self.lock = Lock(self.path.with_suffix(".lock"))
|
|
227
|
+
self.failed: str | None = None
|
|
228
|
+
self._stream = None
|
|
229
|
+
|
|
230
|
+
def open_existing(self) -> None:
|
|
231
|
+
"""Resume: lock first, then repair and continue the same file."""
|
|
232
|
+
self.lock.acquire()
|
|
233
|
+
try:
|
|
234
|
+
_repair_tail(self.path)
|
|
235
|
+
self._stream = self.path.open("ab")
|
|
236
|
+
except OSError:
|
|
237
|
+
self.lock.release()
|
|
238
|
+
raise
|
|
239
|
+
|
|
240
|
+
def _create(self) -> None:
|
|
241
|
+
self.lock.acquire()
|
|
242
|
+
self._stream = self.path.open("xb")
|
|
243
|
+
self._write(
|
|
244
|
+
{
|
|
245
|
+
"type": "session",
|
|
246
|
+
"v": 1,
|
|
247
|
+
"id": self.session.id,
|
|
248
|
+
"cwd": str(self.cwd),
|
|
249
|
+
"created": _now(),
|
|
250
|
+
"mosscode": version("mosscode"),
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def _write(self, record: dict) -> None:
|
|
255
|
+
line = json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
256
|
+
self._stream.write(line.encode("utf-8"))
|
|
257
|
+
self._stream.flush()
|
|
258
|
+
|
|
259
|
+
def append(self, record: dict) -> str | None:
|
|
260
|
+
"""None on success (or after an earlier failure); the one-time notice on the first failure."""
|
|
261
|
+
if self.failed is not None:
|
|
262
|
+
return None
|
|
263
|
+
try:
|
|
264
|
+
if self._stream is None:
|
|
265
|
+
self._create()
|
|
266
|
+
self._write(record)
|
|
267
|
+
return None
|
|
268
|
+
except (OSError, SessionLocked, ValueError, TypeError) as exc:
|
|
269
|
+
self.failed = getattr(exc, "strerror", None) or str(exc) or type(exc).__name__
|
|
270
|
+
self.close()
|
|
271
|
+
return f"会话存档写入失败:{self.failed},本会话之后的消息不会被保存。"
|
|
272
|
+
|
|
273
|
+
def close(self) -> None:
|
|
274
|
+
stream, self._stream = self._stream, None
|
|
275
|
+
if stream is not None:
|
|
276
|
+
try:
|
|
277
|
+
stream.close()
|
|
278
|
+
except OSError:
|
|
279
|
+
pass
|
|
280
|
+
self.lock.release()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# Listing ------------------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass(frozen=True)
|
|
287
|
+
class SessionSummary:
|
|
288
|
+
id: str
|
|
289
|
+
path: Path
|
|
290
|
+
modified: datetime
|
|
291
|
+
title: str
|
|
292
|
+
messages: int
|
|
293
|
+
locked: bool
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _first_user_line(data: Any) -> str | None:
|
|
297
|
+
message = data.get("message") if isinstance(data, dict) else None
|
|
298
|
+
if not isinstance(message, dict) or message.get("role") != "user" or message.get("synthetic") is not None:
|
|
299
|
+
return None
|
|
300
|
+
command = message.get("command")
|
|
301
|
+
text = (
|
|
302
|
+
command.strip()
|
|
303
|
+
if isinstance(command, str)
|
|
304
|
+
else "".join(
|
|
305
|
+
b.get("text", "")
|
|
306
|
+
for b in message.get("content", [])
|
|
307
|
+
if isinstance(b, dict) and b.get("kind") == "text" and isinstance(b.get("text"), str)
|
|
308
|
+
).strip()
|
|
309
|
+
)
|
|
310
|
+
return text.splitlines()[0][:TITLE_CHARS] if text else None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def read_title(path: Path) -> str:
|
|
314
|
+
"""First real user message, looked for only in the first 64 KiB."""
|
|
315
|
+
try:
|
|
316
|
+
with path.open("rb") as stream:
|
|
317
|
+
head = stream.read(TITLE_BYTES)
|
|
318
|
+
except OSError:
|
|
319
|
+
return "(无标题)"
|
|
320
|
+
for line in head.split(b"\n"):
|
|
321
|
+
if line.startswith(MESSAGE_PREFIX):
|
|
322
|
+
try:
|
|
323
|
+
title = _first_user_line(json.loads(line))
|
|
324
|
+
except ValueError:
|
|
325
|
+
continue
|
|
326
|
+
if title:
|
|
327
|
+
return title
|
|
328
|
+
return "(无标题)"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def count_messages(path: Path) -> int:
|
|
332
|
+
"""Lines starting with {"type":"message", counted on bytes without parsing JSON."""
|
|
333
|
+
pattern = b"\n" + MESSAGE_PREFIX
|
|
334
|
+
count, tail, first = 0, b"", True
|
|
335
|
+
try:
|
|
336
|
+
with path.open("rb") as stream:
|
|
337
|
+
while chunk := stream.read(1 << 20):
|
|
338
|
+
if first:
|
|
339
|
+
count += chunk.startswith(MESSAGE_PREFIX)
|
|
340
|
+
first = False
|
|
341
|
+
data = tail + chunk
|
|
342
|
+
count += data.count(pattern)
|
|
343
|
+
tail = data[-(len(pattern) - 1) :]
|
|
344
|
+
except OSError:
|
|
345
|
+
return 0
|
|
346
|
+
return count
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def list_sessions(root: Path, *, home: Path | None = None, limit: int = 20) -> list[SessionSummary]:
|
|
350
|
+
base = project_sessions(root, home)
|
|
351
|
+
try:
|
|
352
|
+
files = [p for p in base.iterdir() if p.suffix == ".jsonl" and SESSION_ID.fullmatch(p.stem)]
|
|
353
|
+
except OSError:
|
|
354
|
+
return []
|
|
355
|
+
stamped = []
|
|
356
|
+
for path in files:
|
|
357
|
+
try:
|
|
358
|
+
stamped.append((path.stat().st_mtime, path))
|
|
359
|
+
except OSError:
|
|
360
|
+
continue
|
|
361
|
+
stamped.sort(key=lambda item: item[0], reverse=True)
|
|
362
|
+
return [
|
|
363
|
+
SessionSummary(
|
|
364
|
+
path.stem,
|
|
365
|
+
path,
|
|
366
|
+
datetime.fromtimestamp(mtime),
|
|
367
|
+
read_title(path),
|
|
368
|
+
count_messages(path),
|
|
369
|
+
is_locked(path.with_suffix(".lock")),
|
|
370
|
+
)
|
|
371
|
+
for mtime, path in stamped[:limit]
|
|
372
|
+
]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def find_session(root: Path, id: str, *, home: Path | None = None) -> Path | None:
|
|
376
|
+
path = project_sessions(root, home) / f"{id}.jsonl"
|
|
377
|
+
return path if SESSION_ID.fullmatch(id) and path.is_file() else None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# Restoring ----------------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@dataclass(frozen=True)
|
|
384
|
+
class Restored:
|
|
385
|
+
id: str
|
|
386
|
+
history: list[Message]
|
|
387
|
+
skipped: int = 0
|
|
388
|
+
interrupted: int = 0
|
|
389
|
+
unanswered: tuple[str, ...] = ()
|
|
390
|
+
last_time: datetime | None = None
|
|
391
|
+
title: str = "(无标题)"
|
|
392
|
+
stripped: bool = False
|
|
393
|
+
skills: tuple[tuple[str, str, bool], ...] = () # (name, arguments, pinned) in record order.
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _strip(history: Sequence[Message]) -> list[Message]:
|
|
397
|
+
return [replace(m, protocol=None, native=()) for m in history]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _time(record: dict) -> datetime | None:
|
|
401
|
+
value = record.get("time", record.get("created"))
|
|
402
|
+
try:
|
|
403
|
+
stamp = datetime.fromisoformat(value) if isinstance(value, str) else None
|
|
404
|
+
except ValueError:
|
|
405
|
+
return None
|
|
406
|
+
if stamp is not None and stamp.tzinfo is None:
|
|
407
|
+
stamp = stamp.astimezone()
|
|
408
|
+
return stamp
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def repair(history: Sequence[Message]) -> tuple[list[Message], int]:
|
|
412
|
+
"""Pair every tool call with a result: orphan results go, missing ones become `interrupted`."""
|
|
413
|
+
repaired, filled = [], 0
|
|
414
|
+
index = 0
|
|
415
|
+
while index < len(history):
|
|
416
|
+
message = history[index]
|
|
417
|
+
index += 1
|
|
418
|
+
if message.role == "tool":
|
|
419
|
+
continue # Not directly after a tool request: an orphan.
|
|
420
|
+
repaired.append(message)
|
|
421
|
+
if message.role != "assistant" or not message.tool_calls:
|
|
422
|
+
continue
|
|
423
|
+
found = {}
|
|
424
|
+
if index < len(history) and history[index].role == "tool":
|
|
425
|
+
found = {r.call_id: r for r in history[index].tool_results}
|
|
426
|
+
index += 1
|
|
427
|
+
results = []
|
|
428
|
+
for call in message.tool_calls:
|
|
429
|
+
result = found.get(call.id)
|
|
430
|
+
if result is None:
|
|
431
|
+
result = ToolResult(call.id, call.name, INTERRUPTED, True, "interrupted", {"executed": None})
|
|
432
|
+
filled += 1
|
|
433
|
+
results.append(result)
|
|
434
|
+
repaired.append(Message("tool", (), tool_results=tuple(results)))
|
|
435
|
+
return repaired, filled
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def load_session(path: Path, provider: tuple[str, str] | None = None) -> Restored:
|
|
439
|
+
"""Replay the records. provider is the current (name, protocol)."""
|
|
440
|
+
history: list[Message] = []
|
|
441
|
+
skipped, last_time, last_provider = 0, None, None
|
|
442
|
+
skills: list[tuple[str, str, bool]] = []
|
|
443
|
+
with path.open("rb") as stream:
|
|
444
|
+
for raw in stream:
|
|
445
|
+
if not raw.strip():
|
|
446
|
+
continue
|
|
447
|
+
try:
|
|
448
|
+
record = json.loads(raw)
|
|
449
|
+
if not isinstance(record, dict) or not isinstance(record.get("type"), str):
|
|
450
|
+
raise ValueError("记录类型不合法")
|
|
451
|
+
kind = record["type"]
|
|
452
|
+
if kind == "message":
|
|
453
|
+
history.append(message_from_json(record.get("message")))
|
|
454
|
+
elif kind == "compact":
|
|
455
|
+
keep = record.get("keep")
|
|
456
|
+
if type(keep) is not int or keep < 0:
|
|
457
|
+
raise ValueError("keep 不合法")
|
|
458
|
+
boundary = message_from_json(record.get("boundary"))
|
|
459
|
+
history = [boundary] + (history[len(history) - min(keep, len(history)) :] if keep else [])
|
|
460
|
+
elif kind == "provider":
|
|
461
|
+
if not isinstance(record.get("name"), str) or not isinstance(record.get("protocol"), str):
|
|
462
|
+
raise ValueError("provider 记录不合法")
|
|
463
|
+
history = _strip(history)
|
|
464
|
+
last_provider = (record["name"], record["protocol"])
|
|
465
|
+
elif kind == "skill":
|
|
466
|
+
name, arguments, pinned = record.get("name"), record.get("arguments", ""), record.get("pinned")
|
|
467
|
+
if (
|
|
468
|
+
not isinstance(name, str)
|
|
469
|
+
or not name
|
|
470
|
+
or not isinstance(arguments, str)
|
|
471
|
+
or not isinstance(pinned, bool)
|
|
472
|
+
):
|
|
473
|
+
raise ValueError("skill 记录不合法")
|
|
474
|
+
skills.append((name, arguments, pinned))
|
|
475
|
+
elif kind != "session":
|
|
476
|
+
pass # A later version's record type.
|
|
477
|
+
except (ValueError, TypeError, KeyError, UnicodeError):
|
|
478
|
+
skipped += 1
|
|
479
|
+
continue
|
|
480
|
+
last_time = _time(record) or last_time
|
|
481
|
+
stripped = False
|
|
482
|
+
if provider is not None:
|
|
483
|
+
if last_provider is not None:
|
|
484
|
+
differs = last_provider != tuple(provider)
|
|
485
|
+
else:
|
|
486
|
+
protocol = next((m.protocol for m in reversed(history) if m.protocol), None)
|
|
487
|
+
differs = protocol is not None and protocol != provider[1]
|
|
488
|
+
if differs and any(m.protocol or m.native for m in history):
|
|
489
|
+
history, stripped = _strip(history), True
|
|
490
|
+
history, filled = repair(history)
|
|
491
|
+
unanswered = []
|
|
492
|
+
while history and history[-1].role == "user" and history[-1].synthetic is None:
|
|
493
|
+
unanswered.insert(0, history.pop().shown)
|
|
494
|
+
return Restored(
|
|
495
|
+
path.stem, history, skipped, filled, tuple(unanswered), last_time, read_title(path), stripped, tuple(skills)
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# Pruning ------------------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def prune_sessions(base: Path | None = None, now: datetime | None = None, days: int = KEEP_DAYS) -> int:
|
|
503
|
+
"""Delete old session directories this program created (marked); anything else is left alone."""
|
|
504
|
+
base = sessions_root() if base is None else base
|
|
505
|
+
cutoff = ((now or datetime.now()) - timedelta(days=days)).timestamp()
|
|
506
|
+
removed = 0
|
|
507
|
+
try:
|
|
508
|
+
children = list(base.iterdir())
|
|
509
|
+
except OSError:
|
|
510
|
+
return 0
|
|
511
|
+
for child in children:
|
|
512
|
+
marker = child / MARKER
|
|
513
|
+
try:
|
|
514
|
+
if child.is_dir() and not child.is_symlink() and marker.is_file() and marker.stat().st_mtime < cutoff:
|
|
515
|
+
shutil.rmtree(child)
|
|
516
|
+
removed += 1
|
|
517
|
+
except OSError:
|
|
518
|
+
continue
|
|
519
|
+
return removed
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _prune_project(base: Path, cutoff: float) -> int:
|
|
523
|
+
try:
|
|
524
|
+
children = list(base.iterdir())
|
|
525
|
+
except OSError:
|
|
526
|
+
return 0
|
|
527
|
+
ids = {child.stem if child.suffix in {".jsonl", ".lock"} else child.name for child in children}
|
|
528
|
+
removed = 0
|
|
529
|
+
for id in sorted(i for i in ids if SESSION_ID.fullmatch(i)):
|
|
530
|
+
transcript, directory, lock = base / f"{id}.jsonl", base / id, base / f"{id}.lock"
|
|
531
|
+
marked = directory.is_dir() and not directory.is_symlink() and (directory / MARKER).is_file()
|
|
532
|
+
try:
|
|
533
|
+
if transcript.is_file():
|
|
534
|
+
modified = transcript.stat().st_mtime
|
|
535
|
+
elif marked:
|
|
536
|
+
modified = (directory / MARKER).stat().st_mtime
|
|
537
|
+
else:
|
|
538
|
+
continue
|
|
539
|
+
if modified >= cutoff or is_locked(lock):
|
|
540
|
+
continue
|
|
541
|
+
if transcript.is_file():
|
|
542
|
+
transcript.unlink()
|
|
543
|
+
if marked:
|
|
544
|
+
shutil.rmtree(directory)
|
|
545
|
+
lock.unlink(missing_ok=True)
|
|
546
|
+
removed += 1
|
|
547
|
+
except OSError:
|
|
548
|
+
continue # In use or not ours to delete: try again next start.
|
|
549
|
+
return removed
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def prune_all(home: Path | None = None, now: datetime | None = None, days: int = KEEP_DAYS) -> int:
|
|
553
|
+
"""Startup cleanup: every project's sessions, then the old location of tool results."""
|
|
554
|
+
root = mosscode_home(home)
|
|
555
|
+
cutoff = ((now or datetime.now()) - timedelta(days=days)).timestamp()
|
|
556
|
+
removed = 0
|
|
557
|
+
try:
|
|
558
|
+
projects = list((root / "projects").iterdir())
|
|
559
|
+
except OSError:
|
|
560
|
+
projects = []
|
|
561
|
+
for project in projects:
|
|
562
|
+
removed += _prune_project(project / "sessions", cutoff)
|
|
563
|
+
return removed + prune_sessions(root / "sessions", now, days)
|