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.
Files changed (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
@@ -0,0 +1,208 @@
1
+ """Tool system types for CrabCode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ from abc import ABC, abstractmethod
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from typing import TYPE_CHECKING, Any, Callable, Coroutine
11
+
12
+ from crabcode_core.types.config import DEFAULT_FILESYSTEM_TIMEOUT
13
+
14
+ if TYPE_CHECKING:
15
+ from crabcode_core.tools.loading import ToolCatalog
16
+ from crabcode_core.types.message import AssistantMessage, Message
17
+
18
+
19
+ class PermissionBehavior(str, Enum):
20
+ ALLOW = "allow"
21
+ DENY = "deny"
22
+ ASK = "ask"
23
+
24
+
25
+ ToolEventCallback = Callable[[str, str, dict[str, Any]], None]
26
+ """Signature: (tool_name, event_type, data)"""
27
+
28
+
29
+ # Keep this aligned with the Gateway's ImageAttachment validation limit. The
30
+ # limit applies to decoded bytes, not the larger Base64 representation.
31
+ MAX_INLINE_IMAGE_BYTES = 20 * 1024 * 1024
32
+
33
+
34
+ @dataclass
35
+ class PermissionResult:
36
+ behavior: PermissionBehavior = PermissionBehavior.ALLOW
37
+ reason: str | None = None
38
+ updated_input: dict[str, Any] | None = None
39
+ permission_key: str | None = None
40
+
41
+
42
+ @dataclass
43
+ class ToolResult:
44
+ """Result from executing a tool."""
45
+
46
+ data: Any = None
47
+ result_for_model: str = ""
48
+ result_for_display: str | None = None
49
+ is_error: bool = False
50
+ # Inline image attachments for the conversation UI. Each entry carries
51
+ # ``media_type``, base64-encoded ``data``, and an optional ``description``.
52
+ images: list[dict[str, str]] = field(default_factory=list)
53
+
54
+
55
+ @dataclass
56
+ class ToolContext:
57
+ """Context passed to tool execution."""
58
+
59
+ cwd: str = "."
60
+ messages: list[Message] = field(default_factory=list)
61
+ abort_signal: Any | None = None
62
+ session_id: str = ""
63
+ env: dict[str, str] = field(default_factory=dict)
64
+ on_event: ToolEventCallback | None = None
65
+ tool_config: dict[str, Any] = field(default_factory=dict)
66
+ choice_queue: Any | None = None # asyncio.Queue[ChoiceResponseEvent]
67
+ tool_event_queue: Any | None = None # asyncio.Queue[CoreEvent] — for tools to emit events mid-execution
68
+ agent_id: str | None = None
69
+ tool_use_id: str | None = None
70
+ agent_depth: int = 0
71
+ agent_manager: Any | None = None
72
+ lsp_manager: Any | None = None # LSPManager — session-scoped, set during CoreSession.initialize()
73
+ team_id: str | None = None # Team ID the current agent belongs to
74
+ team_manager: Any | None = None # TeamManager — session-scoped
75
+ schedule_manager: Any | None = None # ScheduleManager — session-scoped
76
+ session: Any | None = None # CoreSession — for checkpoint/revert operations
77
+ api_adapter: Any | None = None # Active turn's provider, including subagent/model switches
78
+ model: str | None = None
79
+ tool_catalog: ToolCatalog | None = None
80
+ snapshot_enabled: bool = True
81
+ snapshot_max_size_mb: int = 1024
82
+ filesystem_timeout: float | None = DEFAULT_FILESYSTEM_TIMEOUT
83
+ # ImageContent emitted by a tool during this invocation. This is kept on
84
+ # the per-call context so concurrent tools cannot leak attachments into
85
+ # one another.
86
+ emitted_images: list[dict[str, str]] = field(default_factory=list, repr=False)
87
+
88
+ def emit_image(self, data: bytes, mime_type: str, *, description: str = "") -> None:
89
+ """Append an inline image to this tool invocation's result.
90
+
91
+ This mirrors Codex's ``emitImage({ bytes, mimeType })`` boundary:
92
+ callers provide raw bytes and the transport carries a separate image
93
+ content block instead of embedding binary data in text or Markdown.
94
+ """
95
+ if not isinstance(data, (bytes, bytearray, memoryview)):
96
+ raise TypeError("image data must be bytes")
97
+ raw = bytes(data)
98
+ if not raw:
99
+ raise ValueError("image data must not be empty")
100
+ if len(raw) > MAX_INLINE_IMAGE_BYTES:
101
+ raise ValueError("image data exceeds the 20MB limit")
102
+ media_type = str(mime_type or "").strip()
103
+ if not media_type.lower().startswith("image/"):
104
+ raise ValueError("mime_type must be an image MIME type")
105
+ self.emitted_images.append({
106
+ "media_type": media_type,
107
+ "data": base64.b64encode(raw).decode("ascii"),
108
+ **({"description": description} if description else {}),
109
+ })
110
+
111
+
112
+ CanUseToolFn = Callable[
113
+ ["Tool", dict[str, Any], "ToolContext"],
114
+ Coroutine[Any, Any, PermissionResult],
115
+ ]
116
+
117
+
118
+ class Tool(ABC):
119
+ """Base class for all CrabCode tools."""
120
+
121
+ name: str = ""
122
+ description: str = ""
123
+ input_schema: dict[str, Any] = {}
124
+ is_read_only: bool = False
125
+ is_concurrency_safe: bool = False
126
+ is_enabled: bool = True
127
+ uses_tool_permission_policy: bool = False
128
+
129
+ _cached_prompt: str | None = None
130
+ _background_task: asyncio.Task[Any] | None = None
131
+ _setup_context: ToolContext | None = None
132
+
133
+ @abstractmethod
134
+ async def call(
135
+ self,
136
+ tool_input: dict[str, Any],
137
+ context: ToolContext,
138
+ ) -> ToolResult:
139
+ """Execute the tool with given input."""
140
+ ...
141
+
142
+ async def setup(self, context: ToolContext) -> None:
143
+ """Called once during session init. Override for heavy initialization."""
144
+ self._setup_context = context
145
+
146
+ async def close(self) -> None:
147
+ """Called during session shutdown to release resources."""
148
+ return None
149
+
150
+ def emit_event(self, event_type: str, data: dict[str, Any] | None = None) -> None:
151
+ """Emit a tool event (progress, ready, error, etc.)."""
152
+ if self._setup_context and self._setup_context.on_event:
153
+ self._setup_context.on_event(self.name, event_type, data or {})
154
+
155
+ async def wait_ready(self) -> None:
156
+ """Wait for background initialization to complete."""
157
+ if self._background_task and not self._background_task.done():
158
+ await self._background_task
159
+
160
+ async def get_prompt(self, **kwargs: Any) -> str:
161
+ """Return the tool description for the system prompt / API tools list."""
162
+ return self.description
163
+
164
+ async def validate_input(
165
+ self, tool_input: dict[str, Any]
166
+ ) -> str | None:
167
+ """Validate input. Return error string or None if valid."""
168
+ return None
169
+
170
+ def is_available(self, context: ToolContext) -> bool:
171
+ """Whether the active provider/context supports this tool."""
172
+ return self.is_enabled
173
+
174
+ async def check_permissions(
175
+ self,
176
+ tool_input: dict[str, Any],
177
+ context: ToolContext,
178
+ ) -> PermissionResult:
179
+ """Check if the tool can be used with the given input."""
180
+ return PermissionResult()
181
+
182
+ def get_permission_key(self, tool_input: dict[str, Any]) -> str:
183
+ """Return the permission key used for session-scoped allow rules."""
184
+ return self.name
185
+
186
+ def to_api_schema(self) -> dict[str, Any]:
187
+ """Convert to API tool schema for model requests.
188
+
189
+ Uses the detailed prompt from get_prompt() when available,
190
+ falling back to the short description.
191
+ """
192
+ return {
193
+ "name": self.name,
194
+ "description": self._cached_prompt or self.description,
195
+ "input_schema": self.input_schema,
196
+ }
197
+
198
+ async def resolve_prompt(self, **kwargs: Any) -> None:
199
+ """Pre-resolve the detailed prompt for use in to_api_schema().
200
+
201
+ Call this once during setup so to_api_schema() can include the
202
+ full tool description without being async.
203
+ """
204
+ prompt = await self.get_prompt(**kwargs)
205
+ if prompt and prompt != self.description:
206
+ self._cached_prompt = prompt
207
+ else:
208
+ self._cached_prompt = None
crabcode_core/usage.py ADDED
@@ -0,0 +1,289 @@
1
+ """Request-level, provider-reported token ledger and local calendar aggregation.
2
+
3
+ This store deliberately does not estimate missing usage or rewrite session totals.
4
+ Each network attempt has its own row, including retries and interrupted streams.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from contextlib import closing
11
+ import logging
12
+ import os
13
+ import sqlite3
14
+ import time
15
+ import uuid
16
+ from datetime import date, datetime, time as day_time, timedelta, timezone
17
+ from pathlib import Path
18
+ from typing import Any, AsyncIterator
19
+ from zoneinfo import ZoneInfo
20
+
21
+ from crabcode_core.api.base import APIAdapter, ModelConfig, StreamChunk, usage_int_field
22
+ from crabcode_core.api.error_logging import log_api_error
23
+ from crabcode_core.paths import get_config_home
24
+
25
+ logger = logging.getLogger(__name__)
26
+ recording_error: str | None = None
27
+
28
+
29
+ class UsageStore:
30
+ def __init__(self, path: Path | None = None) -> None:
31
+ self.path = path or get_config_home() / "usage.sqlite3"
32
+
33
+ @property
34
+ def error_marker(self) -> Path:
35
+ return self.path.with_name("usage-recording-error.txt")
36
+
37
+ def mark_error(self) -> None:
38
+ """Make a write gap visible to another CLI/Gateway process when possible."""
39
+ try:
40
+ self.path.parent.mkdir(parents=True, exist_ok=True)
41
+ self.error_marker.write_text(
42
+ "Usage recording failed in a CrabCode process. Historical totals may be incomplete.\n",
43
+ encoding="utf-8",
44
+ )
45
+ except OSError:
46
+ logger.warning("Unable to persist usage recording failure marker", exc_info=True)
47
+
48
+ def _connect(self) -> sqlite3.Connection:
49
+ self.path.parent.mkdir(parents=True, exist_ok=True)
50
+ deadline = time.monotonic() + 3
51
+ while True:
52
+ db = sqlite3.connect(self.path, timeout=3)
53
+ try:
54
+ db.execute("PRAGMA busy_timeout=3000")
55
+ if db.execute("PRAGMA user_version").fetchone()[0] == 0:
56
+ db.execute("PRAGMA journal_mode=WAL")
57
+ db.execute("""CREATE TABLE IF NOT EXISTS requests (
58
+ id TEXT PRIMARY KEY, started_ms INTEGER NOT NULL, ended_ms INTEGER,
59
+ project_key TEXT, project_path TEXT, requested_model TEXT NOT NULL,
60
+ model_id TEXT NOT NULL, provider TEXT NOT NULL, purpose TEXT NOT NULL,
61
+ session_id TEXT, operation_id TEXT, input_tokens INTEGER, output_tokens INTEGER,
62
+ reported_total_tokens INTEGER, cache_read_tokens INTEGER,
63
+ cache_write_tokens INTEGER, reasoning_tokens INTEGER,
64
+ usage_status TEXT NOT NULL, status TEXT NOT NULL, stop_reason TEXT
65
+ )""")
66
+ db.execute("CREATE INDEX IF NOT EXISTS requests_time ON requests(started_ms)")
67
+ db.execute("CREATE INDEX IF NOT EXISTS requests_project_time ON requests(project_key, started_ms)")
68
+ db.execute("CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
69
+ db.execute("INSERT OR REPLACE INTO metadata VALUES ('schema_version', '1')")
70
+ db.execute("PRAGMA user_version=1")
71
+ db.commit()
72
+ return db
73
+ except sqlite3.OperationalError as exc:
74
+ db.close()
75
+ if "locked" not in str(exc).lower() or time.monotonic() >= deadline:
76
+ raise
77
+ time.sleep(0.05)
78
+ except BaseException:
79
+ db.close()
80
+ raise
81
+
82
+ @staticmethod
83
+ def project_key(cwd: str | None) -> str | None:
84
+ return os.path.normcase(os.path.normpath(os.path.abspath(cwd))) if cwd else None
85
+
86
+ def begin(self, request_id: str, started_ms: int, *, cwd: str | None,
87
+ model: str, provider: str, purpose: str, session_id: str | None,
88
+ operation_id: str | None = None) -> None:
89
+ with closing(self._connect()) as db, db:
90
+ db.execute("INSERT OR IGNORE INTO metadata VALUES ('tracking_started_ms', ?)", (str(started_ms),))
91
+ db.execute("""INSERT INTO requests
92
+ (id, started_ms, project_key, project_path, requested_model, model_id,
93
+ provider, purpose, session_id, operation_id, usage_status, status)
94
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'missing', 'running')""",
95
+ (request_id, started_ms, self.project_key(cwd), cwd,
96
+ model, model, provider, purpose, session_id, operation_id))
97
+
98
+ def snapshot(self, request_id: str, usage: dict[str, int], model_id: str | None = None) -> None:
99
+ """Persist changed cumulative usage, including before a process crashes."""
100
+ inputs = usage.get("total_input_tokens", usage.get("input_tokens"))
101
+ outputs = usage.get("output_tokens")
102
+ total = usage.get("total_tokens")
103
+ status = "complete" if inputs is not None and outputs is not None else "partial" if usage else "missing"
104
+ with closing(self._connect()) as db, db:
105
+ db.execute("""UPDATE requests SET input_tokens=?, output_tokens=?,
106
+ reported_total_tokens=?, cache_read_tokens=?, cache_write_tokens=?,
107
+ reasoning_tokens=?, usage_status=?, model_id=COALESCE(?, model_id) WHERE id=?""",
108
+ (inputs, outputs, total, usage.get("cache_read_tokens"),
109
+ usage.get("cache_write_tokens"), usage.get("reasoning_tokens"),
110
+ status, model_id, request_id))
111
+
112
+ def finish(self, request_id: str, usage: dict[str, int],
113
+ status: str, stop_reason: str | None = None, model_id: str | None = None) -> None:
114
+ inputs = usage.get("total_input_tokens", usage.get("input_tokens"))
115
+ outputs = usage.get("output_tokens")
116
+ usage_status = "complete" if inputs is not None and outputs is not None else "partial" if usage else "missing"
117
+ with closing(self._connect()) as db, db:
118
+ db.execute("""UPDATE requests SET ended_ms=?, status=?, stop_reason=?,
119
+ input_tokens=?, output_tokens=?, reported_total_tokens=?,
120
+ cache_read_tokens=?, cache_write_tokens=?, reasoning_tokens=?,
121
+ usage_status=?, model_id=COALESCE(?, model_id) WHERE id=?""",
122
+ (int(time.time() * 1000), status, stop_reason, inputs, outputs,
123
+ usage.get("total_tokens"), usage.get("cache_read_tokens"),
124
+ usage.get("cache_write_tokens"), usage.get("reasoning_tokens"),
125
+ usage_status, model_id, request_id))
126
+
127
+ def daily(self, start: date, end: date, zone: ZoneInfo,
128
+ cwd: str | None = None) -> dict[str, Any]:
129
+ if end < start or (end - start).days >= 366:
130
+ raise ValueError("日期范围必须为 1–366 天")
131
+ lower = int(datetime.combine(start, day_time.min, zone).timestamp() * 1000)
132
+ upper = int(datetime.combine(end + timedelta(days=1), day_time.min, zone).timestamp() * 1000)
133
+ with closing(self._connect()) as db, db:
134
+ row = db.execute("SELECT value FROM metadata WHERE key='tracking_started_ms'").fetchone()
135
+ tracking_start = int(row[0]) if row else None
136
+ args: list[Any] = [lower, upper]
137
+ clause = ""
138
+ if cwd is not None:
139
+ clause = " AND project_key=?"
140
+ args.append(self.project_key(cwd))
141
+ rows = db.execute("""SELECT started_ms, provider, model_id, input_tokens,
142
+ output_tokens, reported_total_tokens, usage_status, status
143
+ FROM requests WHERE started_ms>=? AND started_ms<?""" + clause,
144
+ args).fetchall()
145
+ activity_rows = db.execute("""SELECT provider, model_id, COUNT(*)
146
+ FROM requests WHERE 1=1""" + clause + " GROUP BY provider, model_id",
147
+ [self.project_key(cwd)] if cwd is not None else []).fetchall()
148
+ bounds = db.execute("SELECT MIN(started_ms), MAX(started_ms) FROM requests WHERE 1=1" + clause,
149
+ [self.project_key(cwd)] if cwd is not None else []).fetchone()
150
+
151
+ days: dict[str, dict[str, Any]] = {}
152
+ cursor = start
153
+ while cursor <= end:
154
+ key = cursor.isoformat()
155
+ days[key] = {"date": key, "input_tokens": 0, "output_tokens": 0,
156
+ "total_tokens": None, "request_count": 0,
157
+ "unknown_requests": 0, "missing_requests": 0,
158
+ "partial_requests": 0, "coverage": "unavailable"}
159
+ cursor += timedelta(days=1)
160
+ by_model: dict[str, dict[str, int]] = {}
161
+ model_totals: dict[str, int] = {}
162
+ for started_ms, provider, model_id, input_tokens, output_tokens, reported_total, usage_status, status in rows:
163
+ key = datetime.fromtimestamp(started_ms / 1000, timezone.utc).astimezone(zone).date().isoformat()
164
+ day = days[key]
165
+ day["request_count"] += 1
166
+ if usage_status != "complete" or status not in {"completed", "output_limit"}:
167
+ day["unknown_requests"] += 1
168
+ day["missing_requests" if usage_status == "missing" else "partial_requests"] += 1
169
+ inputs = input_tokens or 0
170
+ outputs = output_tokens or 0
171
+ amount = reported_total if reported_total is not None else inputs + outputs
172
+ day["input_tokens"] += inputs
173
+ day["output_tokens"] += outputs
174
+ day["total_tokens"] = (day["total_tokens"] or 0) + amount
175
+ model_key = f"{provider}/{model_id or '未知模型'}"
176
+ model_day = by_model.setdefault(model_key, {})
177
+ model_day[key] = model_day.get(key, 0) + amount
178
+ model_totals[model_key] = model_totals.get(model_key, 0) + amount
179
+ for key, day in days.items():
180
+ day_start = int(datetime.combine(date.fromisoformat(key), day_time.min, zone).timestamp() * 1000)
181
+ if day["unknown_requests"]:
182
+ day["coverage"] = "partial"
183
+ elif tracking_start is None or day_start < tracking_start:
184
+ day["coverage"] = "partial" if day["request_count"] else "unavailable"
185
+ else:
186
+ day["coverage"] = "complete"
187
+ if day["coverage"] == "complete" and day["total_tokens"] is None:
188
+ day["total_tokens"] = 0
189
+ activity_counts = {f"{provider}/{model_id or '未知模型'}": count
190
+ for provider, model_id, count in activity_rows}
191
+ models = [{"key": model_key, "provider": model_key.split("/", 1)[0],
192
+ "model_id": model_key.split("/", 1)[1], "model": model_key,
193
+ "total_tokens": model_totals[model_key],
194
+ "recorded_request_count": activity_counts[model_key],
195
+ "points": [{"date": key, "total_tokens": totals.get(key, 0) if days[key]["coverage"] != "unavailable" else None}
196
+ for key in days]}
197
+ for model_key, totals in sorted(by_model.items())]
198
+ return {"start": start.isoformat(), "end": end.isoformat(), "timezone": str(zone),
199
+ "scope": "project" if cwd is not None else "global",
200
+ "generated_at": datetime.now(timezone.utc).isoformat(),
201
+ "tracking_started_at": datetime.fromtimestamp(tracking_start / 1000, timezone.utc).isoformat() if tracking_start else None,
202
+ "first_recorded_at": datetime.fromtimestamp(bounds[0] / 1000, timezone.utc).isoformat() if bounds[0] else None,
203
+ "last_recorded_at": datetime.fromtimestamp(bounds[1] / 1000, timezone.utc).isoformat() if bounds[1] else None,
204
+ "days": list(days.values()), "models": models,
205
+ "summary": {"input_tokens": sum(d["input_tokens"] for d in days.values()),
206
+ "output_tokens": sum(d["output_tokens"] for d in days.values()),
207
+ "total_tokens": sum(d["total_tokens"] or 0 for d in days.values()),
208
+ "request_count": sum(d["request_count"] for d in days.values()),
209
+ "unknown_requests": sum(d["unknown_requests"] for d in days.values()),
210
+ "missing_requests": sum(d["missing_requests"] for d in days.values()),
211
+ "partial_requests": sum(d["partial_requests"] for d in days.values())}}
212
+
213
+
214
+ async def tracked_stream_message(
215
+ adapter: APIAdapter, *, messages: Any, system: list[str], tools: list[dict[str, Any]],
216
+ config: ModelConfig, cwd: str | None = None, session_id: str | None = None,
217
+ purpose: str = "conversation", operation_id: str | None = None,
218
+ ) -> AsyncIterator[StreamChunk]:
219
+ """Yield unchanged provider chunks; persist one row per attempt on close."""
220
+ global recording_error
221
+ store = UsageStore()
222
+ request_id = uuid.uuid4().hex
223
+ provider = str(getattr(getattr(adapter, "config", None), "provider", None) or type(adapter).__name__)
224
+ recorded = False
225
+ try:
226
+ await asyncio.to_thread(store.begin, request_id, int(time.time() * 1000),
227
+ cwd=cwd, model=config.model, provider=provider,
228
+ purpose=purpose, session_id=session_id,
229
+ operation_id=operation_id)
230
+ recorded = True
231
+ except Exception:
232
+ recording_error = "使用记录数据库无法写入;部分请求可能未被统计"
233
+ store.mark_error()
234
+ logger.warning("Unable to start usage record", exc_info=True)
235
+ usage: dict[str, int] = {}
236
+ outcome = "interrupted"
237
+ stop_reason: str | None = None
238
+ actual_model: str | None = None
239
+ source = None
240
+ try:
241
+ source = adapter.stream_message(messages=messages, system=system, tools=tools, config=config)
242
+ async for chunk in source:
243
+ if chunk.model_id:
244
+ actual_model = chunk.model_id
245
+ if chunk.usage:
246
+ changed = False
247
+ for key in ("total_input_tokens", "input_tokens", "output_tokens", "total_tokens",
248
+ "cache_read_tokens", "cache_write_tokens", "reasoning_tokens"):
249
+ value, present = usage_int_field(chunk.usage, key)
250
+ if present and (key not in usage or value > usage[key]):
251
+ usage[key] = value
252
+ changed = True
253
+ if changed and recorded:
254
+ try:
255
+ await asyncio.to_thread(store.snapshot, request_id, usage.copy(), actual_model)
256
+ except Exception:
257
+ recording_error = "使用记录数据库无法写入;部分请求可能未被统计"
258
+ store.mark_error()
259
+ logger.warning("Unable to save usage snapshot", exc_info=True)
260
+ if chunk.stop_reason:
261
+ stop_reason = chunk.stop_reason
262
+ if chunk.type == "error":
263
+ outcome = "error"
264
+ log_api_error(adapter, session_id=session_id, model=config.model,
265
+ attempt_id=request_id, purpose=purpose, chunk=chunk)
266
+ yield chunk
267
+ if outcome != "error":
268
+ outcome = "output_limit" if stop_reason in {"max_tokens", "max_output_tokens", "length"} else "completed"
269
+ except (GeneratorExit, asyncio.CancelledError):
270
+ outcome = "interrupted"
271
+ raise
272
+ except BaseException as exc:
273
+ outcome = "error"
274
+ if isinstance(exc, Exception):
275
+ log_api_error(adapter, session_id=session_id, model=config.model,
276
+ attempt_id=request_id, purpose=purpose, exception=exc)
277
+ raise
278
+ finally:
279
+ try:
280
+ if source is not None:
281
+ await source.aclose()
282
+ finally:
283
+ if recorded:
284
+ try:
285
+ await asyncio.to_thread(store.finish, request_id, usage.copy(), outcome, stop_reason, actual_model)
286
+ except Exception:
287
+ recording_error = "使用记录数据库无法写入;部分请求可能未被统计"
288
+ store.mark_error()
289
+ logger.warning("Unable to finish usage record", exc_info=True)
@@ -0,0 +1,29 @@
1
+ """Normalize text for UTF-8 I/O (JSON, HTTP, terminals).
2
+
3
+ Python allows lone surrogate code units in ``str``; strict UTF-8 encoders
4
+ (API clients, ``sys.stdout`` on UTF-8, JSON lines) reject them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ def safe_utf8_str(s: str) -> str:
13
+ """Replace lone surrogates so the string encodes as UTF-8."""
14
+ if not s:
15
+ return s
16
+ return s.encode("utf-8", errors="surrogatepass").decode("utf-8", errors="replace")
17
+
18
+
19
+ def safe_utf8_json_tree(obj: Any) -> Any:
20
+ """Recursively sanitize strings for JSON or API payloads."""
21
+ if isinstance(obj, str):
22
+ return safe_utf8_str(obj)
23
+ if isinstance(obj, dict):
24
+ return {k: safe_utf8_json_tree(v) for k, v in obj.items()}
25
+ if isinstance(obj, list):
26
+ return [safe_utf8_json_tree(v) for v in obj]
27
+ if isinstance(obj, tuple):
28
+ return tuple(safe_utf8_json_tree(v) for v in obj)
29
+ return obj
@@ -0,0 +1,5 @@
1
+ """CrabCode debugger tools."""
2
+
3
+ from crabcode_debugger.tool import DebuggerTool, ProcessDebuggerTool
4
+
5
+ __all__ = ["DebuggerTool", "ProcessDebuggerTool"]