@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.
Files changed (45) hide show
  1. package/README.md +20 -6
  2. package/package.json +1 -1
  3. package/server/VERSION +1 -1
  4. package/server/agent_server.py +5283 -698
  5. package/server/agentsdock_chats.py +2 -1
  6. package/server/chat_mailbox.py +23 -6
  7. package/server/claude_history_repair.py +45 -4
  8. package/server/claude_model_catalog.py +200 -0
  9. package/server/claude_sdk_client.py +63 -24
  10. package/server/claude_side_question.py +1 -8
  11. package/server/codex_app_server.py +90 -30
  12. package/server/codex_auth.py +119 -0
  13. package/server/codex_history_repair.py +79 -14
  14. package/server/codex_provider.py +160 -77
  15. package/server/codex_side_question.py +154 -57
  16. package/server/cursor_agent_client.py +27 -1
  17. package/server/cursor_api_key.py +64 -0
  18. package/server/cursor_history.py +219 -0
  19. package/server/cursor_provider_mcp.py +290 -0
  20. package/server/execution_durability.py +10 -6
  21. package/server/execution_preparation.py +6 -2
  22. package/server/install.sh +282 -78
  23. package/server/instances.sh +15 -0
  24. package/server/interactive_chat_native.py +4 -3
  25. package/server/interactive_chat_projection.py +6 -19
  26. package/server/interactive_chat_share_routes.py +71 -45
  27. package/server/interactive_chat_share_web.py +2 -2
  28. package/server/interactive_chat_shares.py +6 -11
  29. package/server/local_session_ownership.py +232 -0
  30. package/server/opencode_agent_client.py +685 -0
  31. package/server/provider_commands.py +579 -0
  32. package/server/provider_connections.py +665 -0
  33. package/server/provider_usage.py +241 -0
  34. package/server/public_chat_shares.py +60 -62
  35. package/server/public_chat_transcript.py +9 -31
  36. package/server/pyproject.toml +1 -0
  37. package/server/server_instances.py +931 -0
  38. package/server/shared_chat_video_stream.py +21 -2
  39. package/server/shared_chat_videos.py +54 -24
  40. package/server/side_questions.py +493 -10
  41. package/server/title_generation.py +4 -1
  42. package/server/uninstall.sh +95 -17
  43. package/server/update_preparation.py +4 -1
  44. package/server/update_runner.py +16 -0
  45. 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
- MAX_MESSAGE_TEXT_BYTES = 256 * 1024
32
- MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024
33
- MAX_RENDER_BYTES = 16 * 1024 * 1024
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, bounded public conversation."""
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
- total_bytes = _utf8_size(title, "title", MAX_TITLE_CHARACTERS * 4)
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
- serialized_bytes = len(json.dumps({"title": title, "created_at": created_at, "messages": []},
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 CHECK(length(snapshot_json) <= 2097152),
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 immutable v1 rows."""
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
- if version == 1:
192
- cls._create_tables(connection, "_v2")
193
- connection.execute("INSERT INTO public_chat_shares_v2 SELECT * FROM public_chat_shares")
194
- connection.execute("INSERT INTO public_chat_share_revocations_v2 SELECT * FROM public_chat_share_revocations")
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 public_chat_shares_v2 RENAME TO public_chat_shares")
198
- connection.execute("ALTER TABLE public_chat_share_revocations_v2 RENAME TO public_chat_share_revocations")
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 arbitrarily many bounded pages atomically from a message sink.
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
- checked, _ = _snapshot([message], title, created_at)
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) >= MAX_SNAPSHOT_PAGE_MESSAGES or overhead + page_bytes + size + len(page) > MAX_SNAPSHOT_BYTES):
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) or len(encoded) > MAX_SNAPSHOT_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
- rendered = "".join(parts).encode("utf-8")
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
- """Bounded, explicit projection of durable chat text for public snapshots.
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 and limits fail explicitly instead of publishing a truncated log.
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
- if time.monotonic() >= deadline:
238
- raise PublicTranscriptError("Chat snapshot processing timed out; no partial snapshot was created")
239
- line = stream.readline(min(MAX_LINE_BYTES + 1, boundary - consumed))
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
- # Avoid parsing/projecting arbitrary tool results and payloads.
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
- size = len(text.encode("utf-8"))
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 (
@@ -7,6 +7,7 @@ dependencies = [
7
7
  "croniter>=6,<7",
8
8
  "cryptography>=50.0.1,<51",
9
9
  "fastapi>=0.115,<1",
10
+ "httpx>=0.28.1,<1",
10
11
  "pydantic>=2.10,<3",
11
12
  "python-dateutil>=2.9,<3",
12
13
  "python-multipart>=0.0.20,<1",