@agentsdock/server 1.0.7-beta.5 → 1.0.8-beta.2
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.
- package/README.md +20 -6
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +5283 -698
- package/server/agentsdock_chats.py +2 -1
- package/server/chat_mailbox.py +23 -6
- package/server/claude_history_repair.py +45 -4
- package/server/claude_model_catalog.py +200 -0
- package/server/claude_sdk_client.py +63 -24
- package/server/claude_side_question.py +1 -8
- package/server/codex_app_server.py +90 -30
- package/server/codex_auth.py +119 -0
- package/server/codex_history_repair.py +79 -14
- package/server/codex_provider.py +160 -77
- package/server/codex_side_question.py +154 -57
- package/server/cursor_agent_client.py +27 -1
- package/server/cursor_api_key.py +64 -0
- package/server/cursor_history.py +219 -0
- package/server/cursor_provider_mcp.py +290 -0
- package/server/execution_durability.py +10 -6
- package/server/execution_preparation.py +6 -2
- package/server/install.sh +282 -78
- package/server/instances.sh +15 -0
- package/server/interactive_chat_native.py +4 -3
- package/server/interactive_chat_projection.py +6 -19
- package/server/interactive_chat_share_routes.py +71 -45
- package/server/interactive_chat_share_web.py +2 -2
- package/server/interactive_chat_shares.py +6 -11
- package/server/local_session_ownership.py +232 -0
- package/server/opencode_agent_client.py +685 -0
- package/server/provider_commands.py +579 -0
- package/server/provider_connections.py +665 -0
- package/server/provider_usage.py +241 -0
- package/server/public_chat_shares.py +60 -62
- package/server/public_chat_transcript.py +9 -31
- package/server/pyproject.toml +1 -0
- package/server/server_instances.py +931 -0
- package/server/shared_chat_video_stream.py +21 -2
- package/server/shared_chat_videos.py +54 -24
- package/server/side_questions.py +493 -10
- package/server/title_generation.py +4 -1
- package/server/uninstall.sh +95 -17
- package/server/update_preparation.py +4 -1
- package/server/update_runner.py +16 -0
- package/server/uv.lock +3 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Read-only provider allowance observations, separate from chat token usage.
|
|
2
|
+
|
|
3
|
+
Codex exposes an account snapshot and update notifications. Claude exposes
|
|
4
|
+
individual rate-limit events only; an omitted percentage never means zero.
|
|
5
|
+
No credentials or provider payloads are persisted by this module.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import copy
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
import weakref
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def timestamp() -> str:
|
|
20
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def unavailable(backend: str, reason: str = "not_reported", *, account_kind: str = "unknown") -> dict:
|
|
24
|
+
return {"backend": backend, "status": "unavailable", "source": None,
|
|
25
|
+
"account_kind": account_kind, "observed_at": None, "windows": [], "reason": reason}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _number(value: Any, *, minimum: float = 0) -> float | None:
|
|
29
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
30
|
+
return None
|
|
31
|
+
return value if math.isfinite(value) and value >= minimum else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _text(value: Any, limit: int = 100) -> str | None:
|
|
35
|
+
if not isinstance(value, str) or not value or len(value) > limit:
|
|
36
|
+
return None
|
|
37
|
+
return value if all(char.isprintable() for char in value) else None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _credits(value: Any) -> dict | None:
|
|
41
|
+
if not isinstance(value, dict) or not isinstance(value.get("hasCredits"), bool) or not isinstance(value.get("unlimited"), bool):
|
|
42
|
+
return None
|
|
43
|
+
# The native balance has no currency field. Keep its decimal representation;
|
|
44
|
+
# never infer dollars or return arbitrary provider text as a balance.
|
|
45
|
+
balance = value.get("balance")
|
|
46
|
+
if not isinstance(balance, str) or not re.fullmatch(r"\d{1,20}(?:\.\d{1,12})?", balance):
|
|
47
|
+
balance = None
|
|
48
|
+
return {"balance": balance, "has_credits": value["hasCredits"], "unlimited": value["unlimited"]}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _codex_bucket(value: Any, key: str, observed_at: str, previous: dict | None = None) -> dict:
|
|
52
|
+
bucket = copy.deepcopy(previous or {"windows": {}})
|
|
53
|
+
if not isinstance(value, dict):
|
|
54
|
+
return bucket
|
|
55
|
+
label = _text(value.get("limitName")) or (_text(value.get("limitId")) if key != "codex" else None)
|
|
56
|
+
for slot in ("primary", "secondary"):
|
|
57
|
+
if slot not in value:
|
|
58
|
+
continue
|
|
59
|
+
raw = value[slot]
|
|
60
|
+
used = _number(raw.get("usedPercent")) if isinstance(raw, dict) else None
|
|
61
|
+
if used is None:
|
|
62
|
+
# Notifications are sparse rolling updates. Null means unavailable
|
|
63
|
+
# in this observation, not recovery/reset of an earlier allowance.
|
|
64
|
+
continue
|
|
65
|
+
old_window = bucket["windows"].get(slot) or {}
|
|
66
|
+
bucket["windows"][slot] = {
|
|
67
|
+
"id": f"{key}:{slot}", "label": label or old_window.get("label"), "used_percent": used,
|
|
68
|
+
"resets_at": _number(raw.get("resetsAt")) if _number(raw.get("resetsAt")) is not None else old_window.get("resets_at"),
|
|
69
|
+
"window_minutes": _number(raw.get("windowDurationMins"), minimum=1) or old_window.get("window_minutes"),
|
|
70
|
+
"observed_at": observed_at,
|
|
71
|
+
}
|
|
72
|
+
credits = _credits(value.get("credits"))
|
|
73
|
+
if credits is not None:
|
|
74
|
+
if credits["balance"] is None and isinstance(bucket.get("credits"), dict):
|
|
75
|
+
credits["balance"] = bucket["credits"].get("balance")
|
|
76
|
+
bucket["credits"] = credits
|
|
77
|
+
return bucket
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def codex_buckets(payload: Any, observed_at: str, *, previous: dict | None = None) -> dict:
|
|
81
|
+
"""Merge available event fields; full reads pass no previous snapshot."""
|
|
82
|
+
buckets = copy.deepcopy(previous or {})
|
|
83
|
+
if not isinstance(payload, dict):
|
|
84
|
+
return buckets
|
|
85
|
+
many = payload.get("rateLimitsByLimitId")
|
|
86
|
+
if isinstance(many, dict):
|
|
87
|
+
for raw_key, value in list(many.items())[:32]:
|
|
88
|
+
key = _text(raw_key)
|
|
89
|
+
if key:
|
|
90
|
+
buckets[key] = _codex_bucket(value, key, observed_at, buckets.get(key))
|
|
91
|
+
single = payload.get("rateLimits")
|
|
92
|
+
if isinstance(single, dict):
|
|
93
|
+
key = _text(single.get("limitId")) or "codex"
|
|
94
|
+
# The multi-bucket field is authoritative when both are supplied.
|
|
95
|
+
if not isinstance(many, dict) or key not in many:
|
|
96
|
+
buckets[key] = _codex_bucket(single, key, observed_at, buckets.get(key))
|
|
97
|
+
return buckets
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def codex_snapshot(buckets: dict, observed_at: str) -> dict:
|
|
101
|
+
windows = [window for bucket in buckets.values() for window in bucket["windows"].values()]
|
|
102
|
+
credits = (buckets.get("codex") or {}).get("credits")
|
|
103
|
+
if credits is None:
|
|
104
|
+
credits = next((bucket.get("credits") for bucket in buckets.values() if bucket.get("credits") is not None), None)
|
|
105
|
+
if not windows and credits is None:
|
|
106
|
+
return unavailable("codex", account_kind="chatgpt")
|
|
107
|
+
return {"backend": "codex", "status": "available", "source": "codex-account",
|
|
108
|
+
"account_kind": "chatgpt", "observed_at": observed_at, "windows": windows,
|
|
109
|
+
**({"credits": credits} if credits is not None else {})}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _field(value: Any, name: str) -> Any:
|
|
113
|
+
return value.get(name) if isinstance(value, dict) else getattr(value, name, None)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_CLAUDE_WINDOWS = {
|
|
117
|
+
"five_hour": (None, 300), "seven_day": (None, 10080),
|
|
118
|
+
"seven_day_opus": ("Opus", 10080),
|
|
119
|
+
"seven_day_sonnet": ("Sonnet", 10080), "overage": (None, None),
|
|
120
|
+
}
|
|
121
|
+
_CLAUDE_STATUSES = {"allowed", "allowed_warning", "rejected"}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def claude_window(message: Any, observed_at: str) -> dict | None:
|
|
125
|
+
info = _field(message, "rate_limit_info")
|
|
126
|
+
kind = _field(info, "rate_limit_type")
|
|
127
|
+
if not isinstance(kind, str) or kind not in _CLAUDE_WINDOWS:
|
|
128
|
+
return None
|
|
129
|
+
status = _field(info, "status")
|
|
130
|
+
if not isinstance(status, str) or status not in _CLAUDE_STATUSES:
|
|
131
|
+
status = None
|
|
132
|
+
utilization = _number(_field(info, "utilization"))
|
|
133
|
+
reset = _number(_field(info, "resets_at"))
|
|
134
|
+
if status is None and utilization is None and reset is None:
|
|
135
|
+
return None
|
|
136
|
+
label, minutes = _CLAUDE_WINDOWS[kind]
|
|
137
|
+
return {"id": kind, "label": label,
|
|
138
|
+
"used_percent": round(utilization * 100, 6) if utilization is not None else None,
|
|
139
|
+
"resets_at": reset, "window_minutes": minutes, "observed_at": observed_at,
|
|
140
|
+
**({"status": status} if status is not None else {})}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class _CodexState:
|
|
145
|
+
binding: tuple = ()
|
|
146
|
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
147
|
+
snapshot: dict | None = None
|
|
148
|
+
buckets: dict = field(default_factory=dict)
|
|
149
|
+
account_id: str | None = None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ProviderUsage:
|
|
153
|
+
def __init__(self) -> None:
|
|
154
|
+
self._codex: weakref.WeakKeyDictionary[Any, _CodexState] = weakref.WeakKeyDictionary()
|
|
155
|
+
self._claude: dict[str, tuple[str, dict]] = {}
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _binding(manager: Any) -> tuple:
|
|
159
|
+
return (manager.generation, manager.client.account_epoch, manager.ready)
|
|
160
|
+
|
|
161
|
+
def invalidate_codex(self, manager: Any) -> None:
|
|
162
|
+
state = self._codex.get(manager)
|
|
163
|
+
if state is not None:
|
|
164
|
+
state.binding, state.snapshot, state.buckets, state.account_id = (), None, {}, None
|
|
165
|
+
|
|
166
|
+
async def read_codex(self, manager: Any, *, refresh: bool = False) -> dict:
|
|
167
|
+
if getattr(manager, "_agentsdock_provider_revision", None):
|
|
168
|
+
return unavailable("codex", "custom_endpoint", account_kind="custom")
|
|
169
|
+
state = self._codex.setdefault(manager, _CodexState())
|
|
170
|
+
async with state.lock:
|
|
171
|
+
if not refresh and state.snapshot is not None and state.binding == self._binding(manager):
|
|
172
|
+
return copy.deepcopy(state.snapshot)
|
|
173
|
+
try:
|
|
174
|
+
# Existing native authentication only. No login, token refresh,
|
|
175
|
+
# billing API, thread, or model request is issued here.
|
|
176
|
+
account = await manager.request("account/read", {"refreshToken": False})
|
|
177
|
+
binding = self._binding(manager)
|
|
178
|
+
raw_account = account.get("account") if isinstance(account, dict) else None
|
|
179
|
+
kind = raw_account.get("type") if isinstance(raw_account, dict) else None
|
|
180
|
+
if kind != "chatgpt" or account.get("requiresOpenaiAuth") is False:
|
|
181
|
+
result = unavailable("codex", "account_usage_unavailable",
|
|
182
|
+
account_kind="api_key" if kind == "apiKey" else "custom" if kind == "chatgpt" else "unknown")
|
|
183
|
+
state.buckets = {}
|
|
184
|
+
else:
|
|
185
|
+
payload = await manager.request("account/rateLimits/read", {})
|
|
186
|
+
if binding != self._binding(manager) or self._codex.get(manager) is not state:
|
|
187
|
+
return unavailable("codex", "account_changed")
|
|
188
|
+
state.buckets = codex_buckets(payload, timestamp())
|
|
189
|
+
state.account_id = _text(payload.get("accountId"), 256) if isinstance(payload, dict) else None
|
|
190
|
+
result = codex_snapshot(state.buckets, timestamp())
|
|
191
|
+
state.binding, state.snapshot = binding, result
|
|
192
|
+
return copy.deepcopy(result)
|
|
193
|
+
except Exception:
|
|
194
|
+
# Neither provider diagnostics nor old account values are a
|
|
195
|
+
# usable balance. A failed refresh stays non-fatal to the chat.
|
|
196
|
+
result = unavailable("codex", "temporarily_unavailable")
|
|
197
|
+
state.snapshot = None
|
|
198
|
+
state.buckets = {}
|
|
199
|
+
state.binding = self._binding(manager)
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
def observe_codex(self, manager: Any, notification: dict) -> bool:
|
|
203
|
+
state = self._codex.get(manager)
|
|
204
|
+
if state is None or state.snapshot is None or state.snapshot.get("account_kind") != "chatgpt":
|
|
205
|
+
return False
|
|
206
|
+
if getattr(manager, "_agentsdock_provider_revision", None) or state.binding != self._binding(manager):
|
|
207
|
+
self.invalidate_codex(manager)
|
|
208
|
+
return True
|
|
209
|
+
payload = notification.get("params")
|
|
210
|
+
if not isinstance(payload, dict):
|
|
211
|
+
return False
|
|
212
|
+
account_id = _text(payload.get("accountId"), 256)
|
|
213
|
+
if state.account_id and account_id and account_id != state.account_id:
|
|
214
|
+
self.invalidate_codex(manager)
|
|
215
|
+
return True
|
|
216
|
+
observed_at = timestamp()
|
|
217
|
+
state.buckets = codex_buckets(payload, observed_at, previous=state.buckets)
|
|
218
|
+
state.snapshot = codex_snapshot(state.buckets, observed_at)
|
|
219
|
+
return True
|
|
220
|
+
|
|
221
|
+
def observe_claude(self, session_id: str, generation: str | None, message: Any) -> bool:
|
|
222
|
+
if not generation:
|
|
223
|
+
return False
|
|
224
|
+
observed_at = timestamp()
|
|
225
|
+
window = claude_window(message, observed_at)
|
|
226
|
+
if window is None:
|
|
227
|
+
return False
|
|
228
|
+
old_generation, old_windows = self._claude.get(session_id, (None, {}))
|
|
229
|
+
windows = dict(old_windows) if old_generation == generation else {}
|
|
230
|
+
windows[window["id"]] = window
|
|
231
|
+
self._claude[session_id] = (generation, windows)
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
def read_claude(self, session_id: str, generation: str | None) -> dict:
|
|
235
|
+
old_generation, windows = self._claude.get(session_id, (None, {}))
|
|
236
|
+
if not generation or old_generation != generation:
|
|
237
|
+
self._claude.pop(session_id, None)
|
|
238
|
+
return unavailable("claude")
|
|
239
|
+
return {"backend": "claude", "status": "available", "source": "claude-events",
|
|
240
|
+
"account_kind": "subscription", "observed_at": max(w["observed_at"] for w in windows.values()),
|
|
241
|
+
"windows": copy.deepcopy(list(windows.values()))}
|
|
@@ -28,10 +28,9 @@ from typing import Any, Callable, Iterator
|
|
|
28
28
|
from shared_chat_videos import normalize_shared_chat_videos
|
|
29
29
|
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
MAX_SNAPSHOT_PAGE_MESSAGES = 100
|
|
31
|
+
# Pagination targets, not quotas: one complete message may exceed a page target.
|
|
32
|
+
TARGET_SNAPSHOT_PAGE_BYTES = 2 * 1024 * 1024
|
|
33
|
+
TARGET_SNAPSHOT_PAGE_MESSAGES = 100
|
|
35
34
|
MAX_TITLE_CHARACTERS = 256
|
|
36
35
|
MAX_LIST_LIMIT = 100
|
|
37
36
|
MAX_UNIX_TIMESTAMP = 253402300799
|
|
@@ -41,7 +40,7 @@ SHARE_ID_PATTERN = re.compile(r"share_[a-f0-9]{32}\Z", re.ASCII)
|
|
|
41
40
|
|
|
42
41
|
|
|
43
42
|
class PublicChatShareValidationError(ValueError):
|
|
44
|
-
"""Input is not an already-projected
|
|
43
|
+
"""Input is not an already-projected public conversation."""
|
|
45
44
|
|
|
46
45
|
|
|
47
46
|
class PublicChatShareUnavailable(LookupError):
|
|
@@ -62,15 +61,15 @@ def _timestamp(value: Any, label: str) -> int | float:
|
|
|
62
61
|
return value
|
|
63
62
|
|
|
64
63
|
|
|
65
|
-
def _utf8_size(value: str, label: str, limit: int) -> int:
|
|
64
|
+
def _utf8_size(value: str, label: str, limit: int | None = None) -> int:
|
|
66
65
|
# Check character count first to avoid encoding a grossly oversized input.
|
|
67
|
-
if len(value) > limit:
|
|
66
|
+
if limit is not None and len(value) > limit:
|
|
68
67
|
raise PublicChatShareValidationError(f"{label} is too large.")
|
|
69
68
|
try:
|
|
70
69
|
length = len(value.encode("utf-8"))
|
|
71
70
|
except UnicodeEncodeError as exc:
|
|
72
71
|
raise PublicChatShareValidationError(f"{label} must be valid UTF-8.") from exc
|
|
73
|
-
if length > limit:
|
|
72
|
+
if limit is not None and length > limit:
|
|
74
73
|
raise PublicChatShareValidationError(f"{label} is too large.")
|
|
75
74
|
return length
|
|
76
75
|
|
|
@@ -82,47 +81,39 @@ def _session_id(value: Any) -> str:
|
|
|
82
81
|
return value
|
|
83
82
|
|
|
84
83
|
|
|
84
|
+
def _projected_message(message: Any) -> dict[str, Any]:
|
|
85
|
+
if not isinstance(message, dict) or set(message) - {"role", "text", "timestamp", "videos"}:
|
|
86
|
+
raise PublicChatShareValidationError("Messages may contain only role, text, timestamp, and videos.")
|
|
87
|
+
role = message.get("role")
|
|
88
|
+
text = message.get("text")
|
|
89
|
+
if role not in ("user", "assistant") or not isinstance(text, str):
|
|
90
|
+
raise PublicChatShareValidationError("Each message needs a user/assistant role and text.")
|
|
91
|
+
_utf8_size(text, "message text")
|
|
92
|
+
item = {"role": role, "text": text}
|
|
93
|
+
if "videos" in message:
|
|
94
|
+
try:
|
|
95
|
+
videos = normalize_shared_chat_videos(message["videos"])
|
|
96
|
+
except ValueError as exc:
|
|
97
|
+
raise PublicChatShareValidationError("Invalid shared video metadata.") from exc
|
|
98
|
+
if videos:
|
|
99
|
+
item["videos"] = videos
|
|
100
|
+
if "timestamp" in message:
|
|
101
|
+
item["timestamp"] = _timestamp(message["timestamp"], "message timestamp")
|
|
102
|
+
return item
|
|
103
|
+
|
|
104
|
+
|
|
85
105
|
def _snapshot(messages: Any, title: Any, created_at: Any) -> tuple[dict[str, Any], bytes]:
|
|
86
106
|
if title is None:
|
|
87
107
|
title = DEFAULT_TITLE
|
|
88
108
|
if not isinstance(title, str) or not title.strip() or len(title) > MAX_TITLE_CHARACTERS:
|
|
89
109
|
raise PublicChatShareValidationError("title must contain 1 to 256 characters.")
|
|
90
|
-
|
|
110
|
+
_utf8_size(title, "title", MAX_TITLE_CHARACTERS * 4)
|
|
91
111
|
_timestamp(created_at, "created_at")
|
|
92
112
|
if not isinstance(messages, list) or not messages:
|
|
93
113
|
raise PublicChatShareValidationError("messages must contain at least one item.")
|
|
94
|
-
|
|
95
|
-
ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8"))
|
|
96
|
-
projected: list[dict[str, Any]] = []
|
|
97
|
-
for message in messages:
|
|
98
|
-
if not isinstance(message, dict) or set(message) - {"role", "text", "timestamp", "videos"}:
|
|
99
|
-
raise PublicChatShareValidationError("Messages may contain only role, text, timestamp, and videos.")
|
|
100
|
-
role = message.get("role")
|
|
101
|
-
text = message.get("text")
|
|
102
|
-
if role not in ("user", "assistant") or not isinstance(text, str):
|
|
103
|
-
raise PublicChatShareValidationError("Each message needs a user/assistant role and text.")
|
|
104
|
-
total_bytes += _utf8_size(text, "message text", MAX_MESSAGE_TEXT_BYTES)
|
|
105
|
-
if total_bytes > MAX_SNAPSHOT_BYTES:
|
|
106
|
-
raise PublicChatShareValidationError("Snapshot is too large.")
|
|
107
|
-
item = {"role": role, "text": text}
|
|
108
|
-
if "videos" in message:
|
|
109
|
-
try:
|
|
110
|
-
videos = normalize_shared_chat_videos(message["videos"])
|
|
111
|
-
except ValueError as exc:
|
|
112
|
-
raise PublicChatShareValidationError("Invalid shared video metadata.") from exc
|
|
113
|
-
if videos:
|
|
114
|
-
item["videos"] = videos
|
|
115
|
-
if "timestamp" in message:
|
|
116
|
-
item["timestamp"] = _timestamp(message["timestamp"], "message timestamp")
|
|
117
|
-
serialized_bytes += len(json.dumps(item, ensure_ascii=False, separators=(",", ":"),
|
|
118
|
-
allow_nan=False).encode("utf-8")) + bool(projected)
|
|
119
|
-
if serialized_bytes > MAX_SNAPSHOT_BYTES:
|
|
120
|
-
raise PublicChatShareValidationError("Snapshot is too large.")
|
|
121
|
-
projected.append(item)
|
|
114
|
+
projected = [_projected_message(message) for message in messages]
|
|
122
115
|
snapshot = {"title": title, "created_at": created_at, "messages": projected}
|
|
123
116
|
encoded = json.dumps(snapshot, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
|
124
|
-
if len(encoded) > MAX_SNAPSHOT_BYTES:
|
|
125
|
-
raise PublicChatShareValidationError("Snapshot is too large.")
|
|
126
117
|
return snapshot, encoded
|
|
127
118
|
|
|
128
119
|
|
|
@@ -171,7 +162,7 @@ class PublicChatShareStore:
|
|
|
171
162
|
created_at REAL NOT NULL,
|
|
172
163
|
expires_at REAL,
|
|
173
164
|
message_count INTEGER NOT NULL CHECK(message_count >= 1),
|
|
174
|
-
snapshot_json BLOB NOT NULL
|
|
165
|
+
snapshot_json BLOB NOT NULL,
|
|
175
166
|
snapshot_sha256 BLOB NOT NULL CHECK(length(snapshot_sha256) = 32)
|
|
176
167
|
)"""
|
|
177
168
|
)
|
|
@@ -181,21 +172,37 @@ class PublicChatShareStore:
|
|
|
181
172
|
revoked_at REAL NOT NULL
|
|
182
173
|
)"""
|
|
183
174
|
)
|
|
175
|
+
connection.execute(f"""CREATE TABLE IF NOT EXISTS public_chat_share_pages{suffix} (
|
|
176
|
+
share_id TEXT NOT NULL REFERENCES public_chat_shares{suffix}(share_id) DEFERRABLE INITIALLY DEFERRED,
|
|
177
|
+
page_index INTEGER NOT NULL CHECK(page_index >= 1),
|
|
178
|
+
snapshot_json BLOB NOT NULL,
|
|
179
|
+
snapshot_sha256 BLOB NOT NULL CHECK(length(snapshot_sha256) = 32),
|
|
180
|
+
PRIMARY KEY(share_id, page_index))""")
|
|
184
181
|
|
|
185
182
|
@classmethod
|
|
186
183
|
def _ensure_schema(cls, connection):
|
|
187
|
-
"""Authenticated-write-only, atomic preservation of
|
|
184
|
+
"""Authenticated-write-only, atomic preservation of existing snapshots."""
|
|
188
185
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
|
189
186
|
if version not in (0, 1, 2, 3):
|
|
190
187
|
raise OSError("Unsupported public chat share database version.")
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
188
|
+
# Only the old SQL size constraints change; columns and persisted JSON
|
|
189
|
+
# are identical. Keep schema version 3 so a previous server can reopen
|
|
190
|
+
# its existing shares after rollback instead of refusing the database.
|
|
191
|
+
bounded_schema = any(re.search(r"CHECK\s*\(\s*length\s*\(\s*snapshot_json\s*\)", row[0] or "", re.IGNORECASE)
|
|
192
|
+
for row in connection.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name IN ('public_chat_shares','public_chat_share_pages')"))
|
|
193
|
+
if version == 1 or bounded_schema:
|
|
194
|
+
cls._create_tables(connection, "_expanded")
|
|
195
|
+
connection.execute("INSERT INTO public_chat_shares_expanded SELECT * FROM public_chat_shares")
|
|
196
|
+
connection.execute("INSERT INTO public_chat_share_revocations_expanded SELECT * FROM public_chat_share_revocations")
|
|
197
|
+
has_pages = connection.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='public_chat_share_pages'").fetchone()
|
|
198
|
+
if has_pages:
|
|
199
|
+
connection.execute("INSERT INTO public_chat_share_pages_expanded SELECT * FROM public_chat_share_pages")
|
|
200
|
+
connection.execute("DROP TABLE public_chat_share_pages")
|
|
195
201
|
connection.execute("DROP TABLE public_chat_share_revocations")
|
|
196
202
|
connection.execute("DROP TABLE public_chat_shares")
|
|
197
|
-
connection.execute("ALTER TABLE
|
|
198
|
-
connection.execute("ALTER TABLE
|
|
203
|
+
connection.execute("ALTER TABLE public_chat_shares_expanded RENAME TO public_chat_shares")
|
|
204
|
+
connection.execute("ALTER TABLE public_chat_share_revocations_expanded RENAME TO public_chat_share_revocations")
|
|
205
|
+
connection.execute("ALTER TABLE public_chat_share_pages_expanded RENAME TO public_chat_share_pages")
|
|
199
206
|
else:
|
|
200
207
|
cls._create_tables(connection)
|
|
201
208
|
connection.execute(
|
|
@@ -208,12 +215,6 @@ class PublicChatShareStore:
|
|
|
208
215
|
f"BEFORE {operation} ON {table} BEGIN "
|
|
209
216
|
"SELECT RAISE(ABORT, 'Public chat shares are immutable'); END"
|
|
210
217
|
)
|
|
211
|
-
connection.execute("""CREATE TABLE IF NOT EXISTS public_chat_share_pages (
|
|
212
|
-
share_id TEXT NOT NULL REFERENCES public_chat_shares(share_id) DEFERRABLE INITIALLY DEFERRED,
|
|
213
|
-
page_index INTEGER NOT NULL CHECK(page_index >= 1),
|
|
214
|
-
snapshot_json BLOB NOT NULL CHECK(length(snapshot_json) <= 2097152),
|
|
215
|
-
snapshot_sha256 BLOB NOT NULL CHECK(length(snapshot_sha256) = 32),
|
|
216
|
-
PRIMARY KEY(share_id, page_index))""")
|
|
217
218
|
for operation in ("UPDATE", "DELETE"):
|
|
218
219
|
connection.execute(f"CREATE TRIGGER IF NOT EXISTS public_chat_share_pages_no_{operation.lower()} "
|
|
219
220
|
f"BEFORE {operation} ON public_chat_share_pages BEGIN "
|
|
@@ -351,11 +352,12 @@ class PublicChatShareStore:
|
|
|
351
352
|
return True
|
|
352
353
|
|
|
353
354
|
def create_streamed_share(self, session_id: str, load_messages: Callable, *, title=None, expires_at=None) -> dict[str, Any]:
|
|
354
|
-
"""Capture
|
|
355
|
+
"""Capture complete messages in paginated snapshots atomically.
|
|
355
356
|
|
|
356
357
|
The loader must emit projected messages synchronously and raise before
|
|
357
358
|
returning if a reviewed digest changed. No whole-chat list/JSON is kept.
|
|
358
359
|
Existing snapshots retain their original first-page representation.
|
|
360
|
+
A message larger than the page target is stored intact on its own page.
|
|
359
361
|
"""
|
|
360
362
|
session_id = _session_id(session_id)
|
|
361
363
|
created_at = _timestamp(self._now(), "current time")
|
|
@@ -392,10 +394,9 @@ class PublicChatShareStore:
|
|
|
392
394
|
|
|
393
395
|
def emit(message) -> None:
|
|
394
396
|
nonlocal total, page_bytes
|
|
395
|
-
|
|
396
|
-
item = checked["messages"][0]
|
|
397
|
+
item = _projected_message(message)
|
|
397
398
|
size = len(json.dumps(item, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8"))
|
|
398
|
-
if page and (len(page) >=
|
|
399
|
+
if page and (len(page) >= TARGET_SNAPSHOT_PAGE_MESSAGES or overhead + page_bytes + size + len(page) > TARGET_SNAPSHOT_PAGE_BYTES):
|
|
399
400
|
flush()
|
|
400
401
|
page.append(item)
|
|
401
402
|
page_bytes += size
|
|
@@ -455,7 +456,7 @@ class PublicChatShareStore:
|
|
|
455
456
|
encoded = row["snapshot_json"]
|
|
456
457
|
digest = row["snapshot_sha256"]
|
|
457
458
|
if (
|
|
458
|
-
not isinstance(encoded, bytes)
|
|
459
|
+
not isinstance(encoded, bytes)
|
|
459
460
|
or not isinstance(digest, bytes)
|
|
460
461
|
or not hmac.compare_digest(hashlib.sha256(encoded).digest(), digest)
|
|
461
462
|
):
|
|
@@ -753,7 +754,4 @@ def render_public_chat_html(snapshot: dict[str, Any], *, page: int = 0, page_cou
|
|
|
753
754
|
parts.append('</div>')
|
|
754
755
|
parts.append('</section>')
|
|
755
756
|
parts.append(f'</div>{navigation}<footer class="conversation-footer"><p>This is a saved copy. Replies and updates stay in the original chat.</p><p class="footer-brand">Shared with AgentsDock</p></footer><div id="conversation-end"></div></main></body></html>')
|
|
756
|
-
|
|
757
|
-
if len(rendered) > MAX_RENDER_BYTES:
|
|
758
|
-
raise PublicChatShareValidationError("Rendered snapshot is too large.")
|
|
759
|
-
return rendered
|
|
757
|
+
return "".join(parts).encode("utf-8")
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Record-at-a-time projection of durable chat text for public snapshots.
|
|
2
2
|
|
|
3
3
|
Never imports the server or reads provider logs. Callers supply the existing
|
|
4
4
|
private-event projection and a validated events path. Nothing runs on import.
|
|
@@ -13,20 +13,11 @@ import os
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
import re
|
|
15
15
|
import stat
|
|
16
|
-
import time
|
|
17
16
|
from typing import Callable
|
|
18
17
|
|
|
19
18
|
from shared_chat_videos import normalize_shared_chat_videos
|
|
20
19
|
|
|
21
|
-
# Retained for the legacy incremental text-view adapter, not full snapshots.
|
|
22
|
-
MAX_LOG_BYTES = 64 * 1024 * 1024
|
|
23
|
-
MAX_LINE_BYTES = 1024 * 1024
|
|
24
|
-
MAX_RECORDS = 100_000
|
|
25
|
-
MAX_MESSAGES = 1_000
|
|
26
|
-
MAX_SCAN_SECONDS = 30
|
|
27
20
|
MAX_SNAPSHOT_BOUNDARY = (1 << 53) - 1
|
|
28
|
-
MAX_TEXT_BYTES = 2 * 1024 * 1024
|
|
29
|
-
MAX_MESSAGE_BYTES = 256 * 1024
|
|
30
21
|
MAX_UNIX_TIMESTAMP = 253402300799
|
|
31
22
|
|
|
32
23
|
|
|
@@ -164,8 +155,6 @@ def make_public_event_projector(
|
|
|
164
155
|
)
|
|
165
156
|
if internal:
|
|
166
157
|
(private_runs if run else private_segments).add(run)
|
|
167
|
-
if len(private_runs) + len(private_segments) > MAX_RECORDS:
|
|
168
|
-
raise PublicTranscriptError("Chat history metadata exceeds the snapshot processing limit")
|
|
169
158
|
if run in private_runs or run in private_segments or not event_is_visible(event):
|
|
170
159
|
return None
|
|
171
160
|
projected = project_provider_event(event, session_id)
|
|
@@ -184,8 +173,6 @@ def make_public_event_projector(
|
|
|
184
173
|
and _complete_imported_delivery_envelope(prompt)
|
|
185
174
|
):
|
|
186
175
|
private_segments.add(run)
|
|
187
|
-
if len(private_runs) + len(private_segments) > MAX_RECORDS:
|
|
188
|
-
raise PublicTranscriptError("Chat history metadata exceeds the snapshot processing limit")
|
|
189
176
|
prompt = ""
|
|
190
177
|
return {**projected, "prompt": prompt}
|
|
191
178
|
|
|
@@ -205,7 +192,9 @@ def read_public_transcript(
|
|
|
205
192
|
exact projected messages. Derived egress repair state must not change the
|
|
206
193
|
text between preview and creation without requiring a fresh review. An incomplete
|
|
207
194
|
final write is not a message and is left out of a preview. Corrupt complete
|
|
208
|
-
records
|
|
195
|
+
records fail explicitly instead of publishing a truncated log. Records are
|
|
196
|
+
read one at a time without a chat-size or message-size quota; a large private
|
|
197
|
+
tool record is still excluded by the same event projection as a small one.
|
|
209
198
|
"""
|
|
210
199
|
if through_bytes is not None and (
|
|
211
200
|
type(through_bytes) is not int or not 0 < through_bytes <= MAX_SNAPSHOT_BOUNDARY
|
|
@@ -217,8 +206,6 @@ def read_public_transcript(
|
|
|
217
206
|
outputs: dict[str, tuple[set[bytes], object, int]] = {}
|
|
218
207
|
message_count = 0
|
|
219
208
|
projected_digest = hashlib.sha256(b"[")
|
|
220
|
-
serialized_bytes = 2 # The JSON array delimiters; include every message's metadata/escaping.
|
|
221
|
-
deadline = time.monotonic() + MAX_SCAN_SECONDS
|
|
222
209
|
digest = hashlib.sha256()
|
|
223
210
|
consumed = 0
|
|
224
211
|
try:
|
|
@@ -234,11 +221,9 @@ def read_public_transcript(
|
|
|
234
221
|
if boundary > initial.st_size:
|
|
235
222
|
raise PublicTranscriptError("Chat changed; preview it again")
|
|
236
223
|
while consumed < boundary:
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
line = stream.readline(
|
|
240
|
-
if len(line) > MAX_LINE_BYTES:
|
|
241
|
-
raise PublicTranscriptError("A chat record exceeds the sharing limit")
|
|
224
|
+
# Bound the read to this immutable snapshot, not an arbitrary
|
|
225
|
+
# record size. readline retains at most the current raw record.
|
|
226
|
+
line = stream.readline(boundary - consumed)
|
|
242
227
|
if not line.endswith(b"\n"):
|
|
243
228
|
if through_bytes is not None:
|
|
244
229
|
raise PublicTranscriptError("Chat changed; preview it again")
|
|
@@ -251,7 +236,7 @@ def read_public_transcript(
|
|
|
251
236
|
raise PublicTranscriptError("Chat history contains an unreadable record") from exc
|
|
252
237
|
if not isinstance(raw, dict):
|
|
253
238
|
raise PublicTranscriptError("Chat history contains an invalid record")
|
|
254
|
-
#
|
|
239
|
+
# Validate raw records, but never project tool results/payloads.
|
|
255
240
|
kind = raw.get("type")
|
|
256
241
|
if not isinstance(kind, str):
|
|
257
242
|
raise PublicTranscriptError("Chat history contains an invalid event type")
|
|
@@ -291,11 +276,9 @@ def read_public_transcript(
|
|
|
291
276
|
# Preserve the reviewed plaintext, including indentation and
|
|
292
277
|
# leading/trailing newlines. Strip only for emptiness/dedup.
|
|
293
278
|
try:
|
|
294
|
-
|
|
279
|
+
text.encode("utf-8")
|
|
295
280
|
except UnicodeEncodeError as exc:
|
|
296
281
|
raise PublicTranscriptError("Chat history contains invalid Unicode text") from exc
|
|
297
|
-
if size > MAX_MESSAGE_BYTES:
|
|
298
|
-
raise PublicTranscriptError("A message exceeds the 256 KiB sharing limit")
|
|
299
282
|
normalized = " ".join(text.split())
|
|
300
283
|
normalized_digest = hashlib.sha256(normalized.encode("utf-8")).digest()
|
|
301
284
|
previous = outputs.get(run)
|
|
@@ -324,9 +307,6 @@ def read_public_transcript(
|
|
|
324
307
|
message["timestamp"] = timestamp
|
|
325
308
|
encoded_message = json.dumps(message, ensure_ascii=False, sort_keys=True,
|
|
326
309
|
separators=(",", ":"), allow_nan=False).encode("utf-8")
|
|
327
|
-
serialized_bytes += len(encoded_message) + bool(message_count)
|
|
328
|
-
if message_sink is None and serialized_bytes > MAX_TEXT_BYTES:
|
|
329
|
-
raise PublicTranscriptError("Readable chat preview exceeds 2 MiB; create a paginated share instead")
|
|
330
310
|
if message_count:
|
|
331
311
|
projected_digest.update(b",")
|
|
332
312
|
projected_digest.update(encoded_message)
|
|
@@ -335,8 +315,6 @@ def read_public_transcript(
|
|
|
335
315
|
else:
|
|
336
316
|
message_sink(message)
|
|
337
317
|
message_count += 1
|
|
338
|
-
if time.monotonic() >= deadline:
|
|
339
|
-
raise PublicTranscriptError("Chat snapshot processing timed out; no partial snapshot was created")
|
|
340
318
|
final = os.fstat(stream.fileno())
|
|
341
319
|
# Appends are allowed, rewrites/truncation of the prefix are not.
|
|
342
320
|
if final.st_size < initial.st_size or (
|