crabcode 0.1.6__py3-none-win_amd64.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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Schedule parsing and next-run calculation helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
from crabcode_core.schedule.models import ScheduleType
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_MONTH_NAMES = {
|
|
12
|
+
"jan": 1,
|
|
13
|
+
"feb": 2,
|
|
14
|
+
"mar": 3,
|
|
15
|
+
"apr": 4,
|
|
16
|
+
"may": 5,
|
|
17
|
+
"jun": 6,
|
|
18
|
+
"jul": 7,
|
|
19
|
+
"aug": 8,
|
|
20
|
+
"sep": 9,
|
|
21
|
+
"oct": 10,
|
|
22
|
+
"nov": 11,
|
|
23
|
+
"dec": 12,
|
|
24
|
+
}
|
|
25
|
+
_WEEKDAY_NAMES = {
|
|
26
|
+
"sun": 0,
|
|
27
|
+
"mon": 1,
|
|
28
|
+
"tue": 2,
|
|
29
|
+
"wed": 3,
|
|
30
|
+
"thu": 4,
|
|
31
|
+
"fri": 5,
|
|
32
|
+
"sat": 6,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def utc_now() -> datetime:
|
|
37
|
+
return datetime.now(timezone.utc)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_interval(value: str) -> int:
|
|
41
|
+
"""Parse a positive interval such as ``30m`` or ``2h`` into seconds."""
|
|
42
|
+
raw = str(value).strip().lower()
|
|
43
|
+
if not raw:
|
|
44
|
+
raise ValueError("interval cannot be empty")
|
|
45
|
+
|
|
46
|
+
multipliers = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
47
|
+
unit = raw[-1]
|
|
48
|
+
number = raw[:-1] if unit in multipliers else raw
|
|
49
|
+
multiplier = multipliers.get(unit, 1)
|
|
50
|
+
try:
|
|
51
|
+
amount = int(number)
|
|
52
|
+
except ValueError as exc:
|
|
53
|
+
raise ValueError(f"invalid interval: {value!r}") from exc
|
|
54
|
+
seconds = amount * multiplier
|
|
55
|
+
if seconds <= 0:
|
|
56
|
+
raise ValueError("interval must be greater than zero")
|
|
57
|
+
return seconds
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_iso_timestamp(value: str) -> datetime:
|
|
61
|
+
"""Parse an aware ISO 8601 timestamp and normalize it to UTC."""
|
|
62
|
+
raw = str(value).strip()
|
|
63
|
+
if raw.endswith(("Z", "z")):
|
|
64
|
+
raw = f"{raw[:-1]}+00:00"
|
|
65
|
+
try:
|
|
66
|
+
parsed = datetime.fromisoformat(raw)
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
raise ValueError(f"invalid ISO timestamp: {value!r}") from exc
|
|
69
|
+
if parsed.tzinfo is None:
|
|
70
|
+
raise ValueError("timestamp must include timezone information")
|
|
71
|
+
return parsed.astimezone(timezone.utc)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse_atom(
|
|
75
|
+
value: str,
|
|
76
|
+
*,
|
|
77
|
+
minimum: int,
|
|
78
|
+
maximum: int,
|
|
79
|
+
names: dict[str, int] | None,
|
|
80
|
+
) -> int:
|
|
81
|
+
lowered = value.strip().lower()
|
|
82
|
+
if names and lowered in names:
|
|
83
|
+
result = names[lowered]
|
|
84
|
+
else:
|
|
85
|
+
try:
|
|
86
|
+
result = int(lowered)
|
|
87
|
+
except ValueError as exc:
|
|
88
|
+
raise ValueError(f"invalid cron value: {value!r}") from exc
|
|
89
|
+
if result < minimum or result > maximum:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"cron value {result} is outside the range {minimum}-{maximum}"
|
|
92
|
+
)
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _expand_cron_field(
|
|
97
|
+
expression: str,
|
|
98
|
+
*,
|
|
99
|
+
minimum: int,
|
|
100
|
+
maximum: int,
|
|
101
|
+
names: dict[str, int] | None = None,
|
|
102
|
+
normalize_weekday: bool = False,
|
|
103
|
+
) -> tuple[set[int], bool]:
|
|
104
|
+
"""Expand one cron field and report whether it is unrestricted."""
|
|
105
|
+
raw = expression.strip().lower()
|
|
106
|
+
if not raw:
|
|
107
|
+
raise ValueError("cron field cannot be empty")
|
|
108
|
+
values: set[int] = set()
|
|
109
|
+
unrestricted = raw == "*"
|
|
110
|
+
|
|
111
|
+
for component in raw.split(","):
|
|
112
|
+
if not component:
|
|
113
|
+
raise ValueError(f"invalid cron field: {expression!r}")
|
|
114
|
+
base, separator, step_text = component.partition("/")
|
|
115
|
+
if separator:
|
|
116
|
+
try:
|
|
117
|
+
step = int(step_text)
|
|
118
|
+
except ValueError as exc:
|
|
119
|
+
raise ValueError(f"invalid cron step: {step_text!r}") from exc
|
|
120
|
+
if step <= 0:
|
|
121
|
+
raise ValueError("cron step must be greater than zero")
|
|
122
|
+
else:
|
|
123
|
+
step = 1
|
|
124
|
+
|
|
125
|
+
if base == "*":
|
|
126
|
+
start, end = minimum, maximum
|
|
127
|
+
elif "-" in base:
|
|
128
|
+
start_text, end_text = base.split("-", 1)
|
|
129
|
+
start = _parse_atom(
|
|
130
|
+
start_text,
|
|
131
|
+
minimum=minimum,
|
|
132
|
+
maximum=maximum,
|
|
133
|
+
names=names,
|
|
134
|
+
)
|
|
135
|
+
end = _parse_atom(
|
|
136
|
+
end_text,
|
|
137
|
+
minimum=minimum,
|
|
138
|
+
maximum=maximum,
|
|
139
|
+
names=names,
|
|
140
|
+
)
|
|
141
|
+
if end < start:
|
|
142
|
+
raise ValueError(f"cron range must be ascending: {base!r}")
|
|
143
|
+
else:
|
|
144
|
+
start = _parse_atom(
|
|
145
|
+
base,
|
|
146
|
+
minimum=minimum,
|
|
147
|
+
maximum=maximum,
|
|
148
|
+
names=names,
|
|
149
|
+
)
|
|
150
|
+
# ``5/10`` means every ten units beginning at five.
|
|
151
|
+
end = maximum if separator else start
|
|
152
|
+
|
|
153
|
+
values.update(range(start, end + 1, step))
|
|
154
|
+
|
|
155
|
+
if normalize_weekday and 7 in values:
|
|
156
|
+
values.discard(7)
|
|
157
|
+
values.add(0)
|
|
158
|
+
if not values:
|
|
159
|
+
raise ValueError(f"cron field has no values: {expression!r}")
|
|
160
|
+
return values, unrestricted
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _cron_fields(expression: str) -> tuple[
|
|
164
|
+
set[int], set[int], set[int], set[int], set[int], bool, bool
|
|
165
|
+
]:
|
|
166
|
+
parts = str(expression).split()
|
|
167
|
+
if len(parts) != 5:
|
|
168
|
+
raise ValueError("cron expression must contain exactly five fields")
|
|
169
|
+
minutes, _ = _expand_cron_field(parts[0], minimum=0, maximum=59)
|
|
170
|
+
hours, _ = _expand_cron_field(parts[1], minimum=0, maximum=23)
|
|
171
|
+
days, day_unrestricted = _expand_cron_field(parts[2], minimum=1, maximum=31)
|
|
172
|
+
months, _ = _expand_cron_field(
|
|
173
|
+
parts[3], minimum=1, maximum=12, names=_MONTH_NAMES
|
|
174
|
+
)
|
|
175
|
+
weekdays, weekday_unrestricted = _expand_cron_field(
|
|
176
|
+
parts[4],
|
|
177
|
+
minimum=0,
|
|
178
|
+
maximum=7,
|
|
179
|
+
names=_WEEKDAY_NAMES,
|
|
180
|
+
normalize_weekday=True,
|
|
181
|
+
)
|
|
182
|
+
return (
|
|
183
|
+
minutes,
|
|
184
|
+
hours,
|
|
185
|
+
days,
|
|
186
|
+
months,
|
|
187
|
+
weekdays,
|
|
188
|
+
day_unrestricted,
|
|
189
|
+
weekday_unrestricted,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def next_cron_run(expression: str, after: datetime | None = None) -> datetime:
|
|
194
|
+
"""Return the next UTC run for a standard five-field cron expression.
|
|
195
|
+
|
|
196
|
+
Day-of-month and day-of-week use the traditional cron OR rule when both
|
|
197
|
+
fields are restricted.
|
|
198
|
+
"""
|
|
199
|
+
(
|
|
200
|
+
minutes,
|
|
201
|
+
hours,
|
|
202
|
+
days,
|
|
203
|
+
months,
|
|
204
|
+
weekdays,
|
|
205
|
+
day_unrestricted,
|
|
206
|
+
weekday_unrestricted,
|
|
207
|
+
) = _cron_fields(expression)
|
|
208
|
+
|
|
209
|
+
base = after or utc_now()
|
|
210
|
+
if base.tzinfo is None:
|
|
211
|
+
base = base.replace(tzinfo=timezone.utc)
|
|
212
|
+
base = base.astimezone(timezone.utc)
|
|
213
|
+
day_start = base.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
214
|
+
|
|
215
|
+
# Iterate by calendar day, then only through the allowed hour/minute
|
|
216
|
+
# combinations. This keeps sparse expressions (for example February
|
|
217
|
+
# 29th) cheap and prevents malformed schedules from blocking an event loop
|
|
218
|
+
# while scanning millions of individual minutes.
|
|
219
|
+
for offset in range(366 * 8 + 1):
|
|
220
|
+
current_day = day_start + timedelta(days=offset)
|
|
221
|
+
cron_weekday = (current_day.weekday() + 1) % 7
|
|
222
|
+
day_matches = current_day.day in days
|
|
223
|
+
weekday_matches = cron_weekday in weekdays
|
|
224
|
+
if day_unrestricted:
|
|
225
|
+
calendar_day_matches = weekday_matches
|
|
226
|
+
elif weekday_unrestricted:
|
|
227
|
+
calendar_day_matches = day_matches
|
|
228
|
+
else:
|
|
229
|
+
calendar_day_matches = day_matches or weekday_matches
|
|
230
|
+
|
|
231
|
+
if (
|
|
232
|
+
current_day.month not in months
|
|
233
|
+
or not calendar_day_matches
|
|
234
|
+
):
|
|
235
|
+
continue
|
|
236
|
+
for hour in sorted(hours):
|
|
237
|
+
for minute in sorted(minutes):
|
|
238
|
+
candidate = current_day.replace(hour=hour, minute=minute)
|
|
239
|
+
if candidate > base:
|
|
240
|
+
return candidate
|
|
241
|
+
raise ValueError("cron expression has no run time within the next eight years")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def compute_next_run(
|
|
245
|
+
schedule: str,
|
|
246
|
+
schedule_type: ScheduleType | str,
|
|
247
|
+
*,
|
|
248
|
+
now: datetime | None = None,
|
|
249
|
+
previous_run: datetime | None = None,
|
|
250
|
+
) -> str:
|
|
251
|
+
"""Validate a schedule and return its next UTC ISO timestamp."""
|
|
252
|
+
current = now or utc_now()
|
|
253
|
+
if current.tzinfo is None:
|
|
254
|
+
current = current.replace(tzinfo=timezone.utc)
|
|
255
|
+
current = current.astimezone(timezone.utc)
|
|
256
|
+
kind = ScheduleType(schedule_type)
|
|
257
|
+
|
|
258
|
+
if kind == ScheduleType.ONCE:
|
|
259
|
+
raw = str(schedule).strip()
|
|
260
|
+
if raw.startswith("+"):
|
|
261
|
+
return (current + timedelta(seconds=parse_interval(raw[1:]))).isoformat()
|
|
262
|
+
return parse_iso_timestamp(raw).isoformat()
|
|
263
|
+
|
|
264
|
+
if kind == ScheduleType.INTERVAL:
|
|
265
|
+
seconds = parse_interval(schedule)
|
|
266
|
+
anchor = previous_run or current
|
|
267
|
+
if anchor.tzinfo is None:
|
|
268
|
+
anchor = anchor.replace(tzinfo=timezone.utc)
|
|
269
|
+
candidate = anchor.astimezone(timezone.utc) + timedelta(seconds=seconds)
|
|
270
|
+
if candidate <= current:
|
|
271
|
+
missed = int((current - candidate).total_seconds() // seconds) + 1
|
|
272
|
+
candidate += timedelta(seconds=missed * seconds)
|
|
273
|
+
return candidate.isoformat()
|
|
274
|
+
|
|
275
|
+
return next_cron_run(schedule, after=current).isoformat()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def earliest_timestamp(values: Iterable[str | None]) -> str | None:
|
|
279
|
+
"""Return the earliest valid ISO timestamp from *values*."""
|
|
280
|
+
parsed: list[datetime] = []
|
|
281
|
+
for value in values:
|
|
282
|
+
if value:
|
|
283
|
+
try:
|
|
284
|
+
parsed.append(parse_iso_timestamp(value))
|
|
285
|
+
except ValueError:
|
|
286
|
+
continue
|
|
287
|
+
return min(parsed).isoformat() if parsed else None
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from crabcode_core.session.meta_db import SessionMetaStore
|
|
2
|
+
from crabcode_core.session.storage import SessionStorage, generate_session_id
|
|
3
|
+
|
|
4
|
+
__all__ = ["CoreSession", "SessionMetaStore", "SessionStorage", "generate_session_id"]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def __getattr__(name: str):
|
|
8
|
+
if name == "CoreSession":
|
|
9
|
+
from crabcode_core.events import CoreSession
|
|
10
|
+
|
|
11
|
+
return CoreSession
|
|
12
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Session export — convert session transcripts to Markdown or JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from crabcode_core.logging_utils import get_logger
|
|
10
|
+
from crabcode_core.session.storage import SessionStorage
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _format_ts(ts: Any) -> str:
|
|
16
|
+
if isinstance(ts, (int, float)) and ts > 0:
|
|
17
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
18
|
+
if isinstance(ts, str) and ts:
|
|
19
|
+
return ts[:19]
|
|
20
|
+
return "unknown"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def export_markdown(session_id: str, cwd: str) -> str:
|
|
24
|
+
"""Export a session transcript as Markdown."""
|
|
25
|
+
storage = SessionStorage(cwd, session_id)
|
|
26
|
+
raw_messages = storage.load_messages(full_history=True)
|
|
27
|
+
meta = storage.meta
|
|
28
|
+
|
|
29
|
+
if not raw_messages and not meta:
|
|
30
|
+
cross = SessionStorage.from_session_id(session_id)
|
|
31
|
+
if cross:
|
|
32
|
+
storage = cross
|
|
33
|
+
raw_messages = storage.load_messages(full_history=True)
|
|
34
|
+
meta = storage.meta
|
|
35
|
+
|
|
36
|
+
lines: list[str] = []
|
|
37
|
+
|
|
38
|
+
title = meta.get("title", "") or f"Session {session_id[:8]}"
|
|
39
|
+
lines.append(f"# {title}")
|
|
40
|
+
lines.append("")
|
|
41
|
+
|
|
42
|
+
header_parts: list[str] = []
|
|
43
|
+
if meta.get("model"):
|
|
44
|
+
header_parts.append(f"**Model:** {meta['model']}")
|
|
45
|
+
if meta.get("provider"):
|
|
46
|
+
header_parts.append(f"**Provider:** {meta['provider']}")
|
|
47
|
+
if meta.get("created_at"):
|
|
48
|
+
header_parts.append(f"**Created:** {_format_ts(meta['created_at'])}")
|
|
49
|
+
if meta.get("cwd"):
|
|
50
|
+
header_parts.append(f"**Project:** `{meta['cwd']}`")
|
|
51
|
+
if meta.get("tokens_used"):
|
|
52
|
+
header_parts.append(f"**Tokens:** {meta['tokens_used']:,}")
|
|
53
|
+
if header_parts:
|
|
54
|
+
lines.append(" | ".join(header_parts))
|
|
55
|
+
lines.append("")
|
|
56
|
+
lines.append("---")
|
|
57
|
+
lines.append("")
|
|
58
|
+
|
|
59
|
+
for raw in raw_messages:
|
|
60
|
+
role = raw.get("type", "user")
|
|
61
|
+
content = raw.get("content", "")
|
|
62
|
+
timestamp = raw.get("timestamp", "")
|
|
63
|
+
|
|
64
|
+
if role == "user":
|
|
65
|
+
lines.append(f"## User")
|
|
66
|
+
elif role == "assistant":
|
|
67
|
+
lines.append(f"## Assistant")
|
|
68
|
+
else:
|
|
69
|
+
lines.append(f"## {role}")
|
|
70
|
+
|
|
71
|
+
if timestamp:
|
|
72
|
+
lines.append(f"*{_format_ts(timestamp)}*")
|
|
73
|
+
lines.append("")
|
|
74
|
+
|
|
75
|
+
text = _extract_text(content)
|
|
76
|
+
tool_uses = _extract_tool_uses(content)
|
|
77
|
+
|
|
78
|
+
if text:
|
|
79
|
+
lines.append(text)
|
|
80
|
+
lines.append("")
|
|
81
|
+
|
|
82
|
+
for tool in tool_uses:
|
|
83
|
+
lines.append(f"<details><summary>Tool: {tool['name']}</summary>")
|
|
84
|
+
lines.append("")
|
|
85
|
+
lines.append("```json")
|
|
86
|
+
lines.append(json.dumps(tool.get("input", {}), indent=2, ensure_ascii=False))
|
|
87
|
+
lines.append("```")
|
|
88
|
+
lines.append("")
|
|
89
|
+
lines.append("</details>")
|
|
90
|
+
lines.append("")
|
|
91
|
+
|
|
92
|
+
return "\n".join(lines)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def export_json(session_id: str, cwd: str) -> str:
|
|
96
|
+
"""Export a session transcript as formatted JSON."""
|
|
97
|
+
storage = SessionStorage(cwd, session_id)
|
|
98
|
+
raw_messages = storage.load_messages(full_history=True)
|
|
99
|
+
meta = storage.meta
|
|
100
|
+
|
|
101
|
+
if not raw_messages and not meta:
|
|
102
|
+
cross = SessionStorage.from_session_id(session_id)
|
|
103
|
+
if cross:
|
|
104
|
+
storage = cross
|
|
105
|
+
raw_messages = storage.load_messages(full_history=True)
|
|
106
|
+
meta = storage.meta
|
|
107
|
+
|
|
108
|
+
data = {
|
|
109
|
+
"session_id": session_id,
|
|
110
|
+
"meta": meta,
|
|
111
|
+
"messages": raw_messages,
|
|
112
|
+
}
|
|
113
|
+
return json.dumps(data, indent=2, ensure_ascii=False)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _extract_text(content: Any) -> str:
|
|
117
|
+
if isinstance(content, str):
|
|
118
|
+
return content
|
|
119
|
+
if isinstance(content, list):
|
|
120
|
+
parts: list[str] = []
|
|
121
|
+
for block in content:
|
|
122
|
+
if isinstance(block, dict):
|
|
123
|
+
if block.get("type") == "text" and "text" in block:
|
|
124
|
+
parts.append(block["text"])
|
|
125
|
+
elif block.get("type") == "thinking" and "thinking" in block:
|
|
126
|
+
parts.append(f"*Thinking: {block['thinking'][:200]}...*")
|
|
127
|
+
return "\n\n".join(parts)
|
|
128
|
+
return ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _extract_tool_uses(content: Any) -> list[dict[str, Any]]:
|
|
132
|
+
if not isinstance(content, list):
|
|
133
|
+
return []
|
|
134
|
+
tools: list[dict[str, Any]] = []
|
|
135
|
+
for block in content:
|
|
136
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
137
|
+
tools.append({
|
|
138
|
+
"name": block.get("name", "unknown"),
|
|
139
|
+
"input": block.get("input", {}),
|
|
140
|
+
})
|
|
141
|
+
return tools
|