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.
Files changed (91) hide show
  1. mosscode/__init__.py +10 -0
  2. mosscode/__main__.py +3 -0
  3. mosscode/agent.py +486 -0
  4. mosscode/agents/__init__.py +249 -0
  5. mosscode/agents/builtin/explore.md +16 -0
  6. mosscode/agents/builtin/general.md +12 -0
  7. mosscode/agents/builtin/plan.md +16 -0
  8. mosscode/agents/runtime.py +685 -0
  9. mosscode/agents/tasks.py +193 -0
  10. mosscode/appearance.py +184 -0
  11. mosscode/builtin_commands.py +1363 -0
  12. mosscode/cli.py +547 -0
  13. mosscode/commands.py +254 -0
  14. mosscode/compaction.py +259 -0
  15. mosscode/config.py +337 -0
  16. mosscode/context.py +102 -0
  17. mosscode/conversation.py +1046 -0
  18. mosscode/coordinator.py +54 -0
  19. mosscode/doctor.py +303 -0
  20. mosscode/hooks/__init__.py +22 -0
  21. mosscode/hooks/actions.py +151 -0
  22. mosscode/hooks/conditions.py +170 -0
  23. mosscode/hooks/config.py +404 -0
  24. mosscode/hooks/engine.py +401 -0
  25. mosscode/hooks/events.py +48 -0
  26. mosscode/launcher.py +177 -0
  27. mosscode/mcp_client.py +460 -0
  28. mosscode/mcp_config.py +216 -0
  29. mosscode/member_ui.py +289 -0
  30. mosscode/memory.py +291 -0
  31. mosscode/memory_extract.py +245 -0
  32. mosscode/messages.py +216 -0
  33. mosscode/paths.py +31 -0
  34. mosscode/permissions.py +733 -0
  35. mosscode/prompts.py +483 -0
  36. mosscode/providers.py +587 -0
  37. mosscode/scrollback.py +106 -0
  38. mosscode/security.py +282 -0
  39. mosscode/serialization.py +121 -0
  40. mosscode/session.py +563 -0
  41. mosscode/setup.py +248 -0
  42. mosscode/skills/__init__.py +455 -0
  43. mosscode/skills/builtin/commit/SKILL.md +15 -0
  44. mosscode/skills/builtin/review/SKILL.md +15 -0
  45. mosscode/skills/builtin/test/SKILL.md +13 -0
  46. mosscode/skills/builtin/test/scripts/detect.py +96 -0
  47. mosscode/skills/builtin/test/tool.json +9 -0
  48. mosscode/teams/__init__.py +216 -0
  49. mosscode/teams/backends.py +259 -0
  50. mosscode/teams/board.py +230 -0
  51. mosscode/teams/lock.py +173 -0
  52. mosscode/teams/mailbox.py +172 -0
  53. mosscode/teams/member.py +427 -0
  54. mosscode/teams/merge.py +207 -0
  55. mosscode/teams/pane.py +20 -0
  56. mosscode/teams/pane_app.py +167 -0
  57. mosscode/teams/process.py +371 -0
  58. mosscode/teams/runtime.py +1355 -0
  59. mosscode/teams/view.py +182 -0
  60. mosscode/tool_budget.py +86 -0
  61. mosscode/tools/__init__.py +38 -0
  62. mosscode/tools/agent.py +127 -0
  63. mosscode/tools/base.py +384 -0
  64. mosscode/tools/diffing.py +16 -0
  65. mosscode/tools/execution.py +99 -0
  66. mosscode/tools/files.py +287 -0
  67. mosscode/tools/memory.py +65 -0
  68. mosscode/tools/plan.py +30 -0
  69. mosscode/tools/process.py +216 -0
  70. mosscode/tools/search.py +229 -0
  71. mosscode/tools/search_worker.py +243 -0
  72. mosscode/tools/shell.py +121 -0
  73. mosscode/tools/skill.py +118 -0
  74. mosscode/tools/team.py +447 -0
  75. mosscode/tools/windows_job.py +75 -0
  76. mosscode/tui.py +2325 -0
  77. mosscode/usage.py +68 -0
  78. mosscode/windows_inline.py +148 -0
  79. mosscode/worktree/__init__.py +29 -0
  80. mosscode/worktree/changes.py +59 -0
  81. mosscode/worktree/git.py +125 -0
  82. mosscode/worktree/manager.py +462 -0
  83. mosscode/worktree/meta.py +82 -0
  84. mosscode/worktree/names.py +52 -0
  85. mosscode/worktree/setup.py +119 -0
  86. mosscode-0.1.0.dist-info/METADATA +92 -0
  87. mosscode-0.1.0.dist-info/RECORD +91 -0
  88. mosscode-0.1.0.dist-info/WHEEL +5 -0
  89. mosscode-0.1.0.dist-info/entry_points.txt +3 -0
  90. mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
  91. 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)