deepy-cli 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. deepy/__init__.py +9 -0
  2. deepy/__main__.py +7 -0
  3. deepy/cli.py +413 -0
  4. deepy/config/__init__.py +21 -0
  5. deepy/config/settings.py +237 -0
  6. deepy/data/__init__.py +1 -0
  7. deepy/data/tools/AskUserQuestion.md +10 -0
  8. deepy/data/tools/WebFetch.md +9 -0
  9. deepy/data/tools/WebSearch.md +9 -0
  10. deepy/data/tools/__init__.py +1 -0
  11. deepy/data/tools/bash.md +7 -0
  12. deepy/data/tools/edit.md +13 -0
  13. deepy/data/tools/modify.md +17 -0
  14. deepy/data/tools/read.md +8 -0
  15. deepy/data/tools/write.md +12 -0
  16. deepy/errors.py +63 -0
  17. deepy/llm/__init__.py +13 -0
  18. deepy/llm/agent.py +31 -0
  19. deepy/llm/context.py +109 -0
  20. deepy/llm/events.py +187 -0
  21. deepy/llm/model_capabilities.py +7 -0
  22. deepy/llm/provider.py +81 -0
  23. deepy/llm/replay.py +120 -0
  24. deepy/llm/runner.py +412 -0
  25. deepy/llm/thinking.py +30 -0
  26. deepy/prompts/__init__.py +6 -0
  27. deepy/prompts/compact.py +100 -0
  28. deepy/prompts/rules.py +24 -0
  29. deepy/prompts/runtime_context.py +98 -0
  30. deepy/prompts/system.py +72 -0
  31. deepy/prompts/tool_docs.py +21 -0
  32. deepy/sessions/__init__.py +17 -0
  33. deepy/sessions/jsonl.py +306 -0
  34. deepy/sessions/manager.py +202 -0
  35. deepy/skills.py +202 -0
  36. deepy/status.py +65 -0
  37. deepy/tools/__init__.py +6 -0
  38. deepy/tools/agents.py +343 -0
  39. deepy/tools/builtin.py +2113 -0
  40. deepy/tools/file_state.py +85 -0
  41. deepy/tools/result.py +54 -0
  42. deepy/tools/shell_utils.py +83 -0
  43. deepy/ui/__init__.py +5 -0
  44. deepy/ui/app.py +118 -0
  45. deepy/ui/ask_user_question.py +182 -0
  46. deepy/ui/exit_summary.py +142 -0
  47. deepy/ui/loading_text.py +87 -0
  48. deepy/ui/markdown.py +152 -0
  49. deepy/ui/message_view.py +546 -0
  50. deepy/ui/prompt_buffer.py +176 -0
  51. deepy/ui/prompt_input.py +286 -0
  52. deepy/ui/session_list.py +140 -0
  53. deepy/ui/session_picker.py +179 -0
  54. deepy/ui/slash_commands.py +67 -0
  55. deepy/ui/styles.py +21 -0
  56. deepy/ui/terminal.py +959 -0
  57. deepy/ui/thinking_state.py +29 -0
  58. deepy/ui/welcome.py +195 -0
  59. deepy/update_check.py +195 -0
  60. deepy/usage.py +192 -0
  61. deepy/utils/__init__.py +15 -0
  62. deepy/utils/debug_logger.py +62 -0
  63. deepy/utils/error_logger.py +107 -0
  64. deepy/utils/json.py +29 -0
  65. deepy/utils/notify.py +66 -0
  66. deepy_cli-0.1.1.dist-info/METADATA +205 -0
  67. deepy_cli-0.1.1.dist-info/RECORD +69 -0
  68. deepy_cli-0.1.1.dist-info/WHEEL +4 -0
  69. deepy_cli-0.1.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Iterable, Mapping
4
+
5
+
6
+ def find_expanded_thinking_id(messages: Iterable[Any]) -> str | None:
7
+ expanded: str | None = None
8
+ for message in messages:
9
+ if _field(message, "role") != "assistant":
10
+ continue
11
+ if _is_thinking_message(message):
12
+ message_id = _field(message, "id")
13
+ expanded = message_id if isinstance(message_id, str) else None
14
+ else:
15
+ expanded = None
16
+ return expanded
17
+
18
+
19
+ def _is_thinking_message(message: Any) -> bool:
20
+ meta = _field(message, "meta")
21
+ if isinstance(meta, Mapping):
22
+ return meta.get("asThinking") is True
23
+ return getattr(meta, "asThinking", False) is True
24
+
25
+
26
+ def _field(value: Any, name: str) -> Any:
27
+ if isinstance(value, Mapping):
28
+ return value.get(name)
29
+ return getattr(value, name, None)
deepy/ui/welcome.py ADDED
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from rich.console import Group
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+
12
+ from deepy.skills import SkillInfo
13
+ from deepy.update_check import VersionUpdate
14
+ from deepy.ui.styles import STYLE_ACCENT, STYLE_ASSISTANT, STYLE_INFO, STYLE_MUTED
15
+ from deepy.ui.slash_commands import (
16
+ BUILTIN_SLASH_COMMANDS,
17
+ format_slash_command_description,
18
+ )
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class WelcomeTip:
23
+ label: str
24
+ description: str
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class WelcomeSetting:
29
+ label: str
30
+ value: str
31
+
32
+
33
+ CORE_WELCOME_COMMANDS = (
34
+ "/resume",
35
+ "/new",
36
+ "/skills",
37
+ )
38
+
39
+ CORE_SHORTCUT_TIPS = (
40
+ WelcomeTip("/", "Command menu"),
41
+ WelcomeTip("Esc", "Interrupt turn"),
42
+ WelcomeTip("Ctrl+D twice", "Quit"),
43
+ )
44
+
45
+ COMPACT_COMMAND_DESCRIPTIONS = {
46
+ "/skills": "List skills",
47
+ "/new": "New session",
48
+ "/resume": "Resume session",
49
+ "/exit": "Quit",
50
+ }
51
+
52
+ DEEPY_ASCII_LOGO = (
53
+ " .-''''-.",
54
+ " .-' >_ '-.",
55
+ " / .----. \\",
56
+ " | | >_ | o |",
57
+ " | '----' |",
58
+ " \\ Deepy /",
59
+ " '-.______.--'",
60
+ )
61
+
62
+
63
+ def format_home_relative_path(value: str | Path, home: str | Path | None = None) -> str:
64
+ path = Path(os.path.abspath(os.path.expanduser(str(value))))
65
+ home_value = str(Path.home()) if home is None else str(home)
66
+ home_path = Path(os.path.abspath(os.path.expanduser(home_value)))
67
+ try:
68
+ relative = path.relative_to(home_path)
69
+ except ValueError:
70
+ return str(path)
71
+ return "~" if str(relative) == "." else f"~/{relative}"
72
+
73
+
74
+ def build_welcome_tips(skills: list[SkillInfo]) -> list[WelcomeTip]:
75
+ del skills
76
+ slash_tips = [
77
+ WelcomeTip(
78
+ label=item.label,
79
+ description=COMPACT_COMMAND_DESCRIPTIONS.get(
80
+ item.label,
81
+ format_slash_command_description(item.description),
82
+ ),
83
+ )
84
+ for item in BUILTIN_SLASH_COMMANDS
85
+ if item.label in CORE_WELCOME_COMMANDS
86
+ ]
87
+ return [*slash_tips, *CORE_SHORTCUT_TIPS]
88
+
89
+
90
+ def build_welcome_settings(
91
+ *,
92
+ model: str,
93
+ thinking_enabled: bool,
94
+ reasoning_effort: str,
95
+ project_root: str | Path,
96
+ current_version: str,
97
+ version_update: VersionUpdate | None = None,
98
+ home: str | Path | None = None,
99
+ ) -> list[WelcomeSetting]:
100
+ settings = [
101
+ WelcomeSetting("Version", _format_version_value(current_version, version_update)),
102
+ WelcomeSetting("Model", model),
103
+ WelcomeSetting("Thinking", "on" if thinking_enabled else "off"),
104
+ WelcomeSetting("Reasoning", reasoning_effort),
105
+ WelcomeSetting("CWD", format_home_relative_path(project_root, home=home)),
106
+ ]
107
+ if version_update is not None:
108
+ settings.append(WelcomeSetting("Update", version_update.install_hint))
109
+ return settings
110
+
111
+
112
+ def _format_version_value(current_version: str, version_update: VersionUpdate | None) -> str:
113
+ if version_update is None:
114
+ return current_version
115
+ return (
116
+ f"{current_version} -> {version_update.latest_version} available "
117
+ f"from {version_update.source}"
118
+ )
119
+
120
+
121
+ def build_deepy_ascii_logo() -> Text:
122
+ logo = Text()
123
+ for index, line in enumerate(DEEPY_ASCII_LOGO):
124
+ if index:
125
+ logo.append("\n")
126
+ logo.append(line, style=f"bold {STYLE_ACCENT}" if "Deepy" in line else STYLE_INFO)
127
+ return logo
128
+
129
+
130
+ def _build_section(title: str, rows: list[WelcomeSetting | WelcomeTip]) -> Table:
131
+ section = Table.grid()
132
+ section.add_column()
133
+ section.add_row(Text(title, style="bold bright_white"))
134
+ section.add_row(Text(""))
135
+
136
+ body = Table.grid(padding=(0, 2))
137
+ body.add_column(style=STYLE_ACCENT, no_wrap=True)
138
+ body.add_column(style="bright_white")
139
+ for row in rows:
140
+ body.add_row(row.label, row.description if isinstance(row, WelcomeTip) else row.value)
141
+ section.add_row(body)
142
+ return section
143
+
144
+
145
+ def build_welcome_panel(
146
+ *,
147
+ model: str,
148
+ thinking_enabled: bool,
149
+ reasoning_effort: str,
150
+ project_root: str | Path,
151
+ skills: list[SkillInfo],
152
+ current_version: str,
153
+ version_update: VersionUpdate | None = None,
154
+ home: str | Path | None = None,
155
+ ) -> Panel:
156
+ settings = build_welcome_settings(
157
+ model=model,
158
+ thinking_enabled=thinking_enabled,
159
+ reasoning_effort=reasoning_effort,
160
+ project_root=project_root,
161
+ current_version=current_version,
162
+ version_update=version_update,
163
+ home=home,
164
+ )
165
+ tips = build_welcome_tips(skills)
166
+
167
+ hero = Table.grid(padding=(0, 3), expand=False)
168
+ hero.add_column()
169
+ hero.add_column(ratio=1)
170
+
171
+ intro = Text()
172
+ intro.append("Deepy\n", style=f"bold {STYLE_ASSISTANT}")
173
+ intro.append("Terminal coding agent for DeepSeek.\n", style="bright_white")
174
+ intro.append("Read, edit, run tools, and keep project context.", style=STYLE_MUTED)
175
+
176
+ hero.add_row(build_deepy_ascii_logo(), intro)
177
+
178
+ body = Table.grid(padding=(0, 4), expand=False)
179
+ body.add_column()
180
+ body.add_column()
181
+ body.add_row(
182
+ _build_section("Session", settings),
183
+ _build_section("Commands", tips[:10]),
184
+ )
185
+
186
+ return Panel(
187
+ Group(
188
+ hero,
189
+ Text(""),
190
+ body,
191
+ ),
192
+ title="Deepy is ready",
193
+ border_style=STYLE_INFO,
194
+ expand=False,
195
+ )
deepy/update_check.py ADDED
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import urllib.error
5
+ import urllib.request
6
+ from dataclasses import dataclass
7
+ from typing import Any, Callable
8
+
9
+ from deepy.utils import json as json_utils
10
+
11
+ DEFAULT_PYPI_PACKAGE = "deepy-cli"
12
+ DEFAULT_GITHUB_REPO = "kirineko/deepy"
13
+ DEFAULT_TIMEOUT_SECONDS = 0.8
14
+
15
+ UrlOpen = Callable[..., Any]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class VersionCandidate:
20
+ version: str
21
+ source: str
22
+ url: str
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class VersionUpdate:
27
+ current_version: str
28
+ latest_version: str
29
+ source: str
30
+ url: str
31
+ install_hint: str
32
+
33
+
34
+ def check_for_version_update(
35
+ current_version: str,
36
+ *,
37
+ pypi_package: str = DEFAULT_PYPI_PACKAGE,
38
+ github_repo: str = DEFAULT_GITHUB_REPO,
39
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
40
+ urlopen: UrlOpen = urllib.request.urlopen,
41
+ ) -> VersionUpdate | None:
42
+ candidates = [
43
+ candidate
44
+ for candidate in (
45
+ fetch_latest_pypi_version(pypi_package, timeout_seconds=timeout_seconds, urlopen=urlopen),
46
+ fetch_latest_github_version(github_repo, timeout_seconds=timeout_seconds, urlopen=urlopen),
47
+ )
48
+ if candidate is not None
49
+ ]
50
+ latest = _latest_candidate(candidates)
51
+ if latest is None or compare_versions(latest.version, current_version) <= 0:
52
+ return None
53
+ return VersionUpdate(
54
+ current_version=current_version,
55
+ latest_version=latest.version,
56
+ source=latest.source,
57
+ url=latest.url,
58
+ install_hint=f"uv tool upgrade {pypi_package}",
59
+ )
60
+
61
+
62
+ def fetch_latest_pypi_version(
63
+ package_name: str,
64
+ *,
65
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
66
+ urlopen: UrlOpen = urllib.request.urlopen,
67
+ ) -> VersionCandidate | None:
68
+ url = f"https://pypi.org/pypi/{package_name}/json"
69
+ try:
70
+ with urlopen(_request(url), timeout=timeout_seconds) as response:
71
+ payload = _read_json_response(response)
72
+ except (OSError, urllib.error.URLError, json_utils.JSONDecodeError):
73
+ return None
74
+ info = payload.get("info") if isinstance(payload, dict) else None
75
+ version = info.get("version") if isinstance(info, dict) else None
76
+ if not isinstance(version, str) or not version.strip():
77
+ return None
78
+ return VersionCandidate(version=version.strip(), source="PyPI", url=f"https://pypi.org/project/{package_name}/")
79
+
80
+
81
+ def fetch_latest_github_version(
82
+ repo: str,
83
+ *,
84
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
85
+ urlopen: UrlOpen = urllib.request.urlopen,
86
+ ) -> VersionCandidate | None:
87
+ release = _fetch_github_release_version(repo, timeout_seconds=timeout_seconds, urlopen=urlopen)
88
+ if release is not None:
89
+ return release
90
+ return _fetch_github_tag_version(repo, timeout_seconds=timeout_seconds, urlopen=urlopen)
91
+
92
+
93
+ def compare_versions(a: str, b: str) -> int:
94
+ left = _version_parts(a)
95
+ right = _version_parts(b)
96
+ width = max(len(left), len(right))
97
+ for index in range(width):
98
+ left_part = left[index] if index < len(left) else 0
99
+ right_part = right[index] if index < len(right) else 0
100
+ if left_part > right_part:
101
+ return 1
102
+ if left_part < right_part:
103
+ return -1
104
+ return 0
105
+
106
+
107
+ def _fetch_github_release_version(
108
+ repo: str,
109
+ *,
110
+ timeout_seconds: float,
111
+ urlopen: UrlOpen,
112
+ ) -> VersionCandidate | None:
113
+ url = f"https://api.github.com/repos/{repo}/releases/latest"
114
+ try:
115
+ with urlopen(_request(url), timeout=timeout_seconds) as response:
116
+ payload = _read_json_response(response)
117
+ except (OSError, urllib.error.URLError, json_utils.JSONDecodeError):
118
+ return None
119
+ tag = payload.get("tag_name") if isinstance(payload, dict) else None
120
+ version = _normalize_tag_version(tag)
121
+ if version is None:
122
+ return None
123
+ html_url = payload.get("html_url") if isinstance(payload, dict) else None
124
+ return VersionCandidate(
125
+ version=version,
126
+ source="GitHub",
127
+ url=html_url if isinstance(html_url, str) and html_url else f"https://github.com/{repo}/releases/latest",
128
+ )
129
+
130
+
131
+ def _fetch_github_tag_version(
132
+ repo: str,
133
+ *,
134
+ timeout_seconds: float,
135
+ urlopen: UrlOpen,
136
+ ) -> VersionCandidate | None:
137
+ url = f"https://api.github.com/repos/{repo}/tags?per_page=1"
138
+ try:
139
+ with urlopen(_request(url), timeout=timeout_seconds) as response:
140
+ payload = _read_json_response(response)
141
+ except (OSError, urllib.error.URLError, json_utils.JSONDecodeError):
142
+ return None
143
+ if not isinstance(payload, list) or not payload:
144
+ return None
145
+ first = payload[0]
146
+ tag = first.get("name") if isinstance(first, dict) else None
147
+ version = _normalize_tag_version(tag)
148
+ if version is None:
149
+ return None
150
+ return VersionCandidate(version=version, source="GitHub", url=f"https://github.com/{repo}/releases/tag/{tag}")
151
+
152
+
153
+ def _latest_candidate(candidates: list[VersionCandidate]) -> VersionCandidate | None:
154
+ latest: VersionCandidate | None = None
155
+ for candidate in candidates:
156
+ if latest is None or compare_versions(candidate.version, latest.version) > 0:
157
+ latest = candidate
158
+ return latest
159
+
160
+
161
+ def _read_json_response(response: Any) -> dict[str, Any] | list[Any]:
162
+ data = response.read()
163
+ if isinstance(data, str):
164
+ text = data
165
+ else:
166
+ text = data.decode("utf-8", errors="replace")
167
+ parsed = json_utils.loads(text)
168
+ return parsed if isinstance(parsed, (dict, list)) else {}
169
+
170
+
171
+ def _request(url: str) -> urllib.request.Request:
172
+ return urllib.request.Request(
173
+ url,
174
+ headers={
175
+ "Accept": "application/json",
176
+ "User-Agent": "Deepy update check",
177
+ },
178
+ method="GET",
179
+ )
180
+
181
+
182
+ def _normalize_tag_version(value: object) -> str | None:
183
+ if not isinstance(value, str):
184
+ return None
185
+ stripped = value.strip()
186
+ if stripped.startswith(("v", "V")):
187
+ stripped = stripped[1:]
188
+ return stripped if _version_parts(stripped) else None
189
+
190
+
191
+ def _version_parts(value: str) -> list[int]:
192
+ match = re.match(r"^\s*v?(\d+(?:\.\d+)*)", value, flags=re.IGNORECASE)
193
+ if not match:
194
+ return []
195
+ return [int(part) for part in match.group(1).split(".")]
deepy/usage.py ADDED
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from typing import Any, Mapping
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class TokenUsage:
9
+ prompt_tokens: int = 0
10
+ completion_tokens: int = 0
11
+ total_tokens: int = 0
12
+ prompt_cache_hit_tokens: int = 0
13
+ prompt_cache_miss_tokens: int = 0
14
+ reasoning_tokens: int = 0
15
+ requests: int = 0
16
+ request_usage_entries: list[dict[str, Any]] = field(default_factory=list)
17
+
18
+ @property
19
+ def known(self) -> bool:
20
+ return any(
21
+ (
22
+ self.prompt_tokens,
23
+ self.completion_tokens,
24
+ self.total_tokens,
25
+ self.prompt_cache_hit_tokens,
26
+ self.prompt_cache_miss_tokens,
27
+ self.reasoning_tokens,
28
+ self.requests,
29
+ )
30
+ )
31
+
32
+ def to_dict(self) -> dict[str, Any]:
33
+ payload = asdict(self)
34
+ return {key: value for key, value in payload.items() if value not in (0, [], None)}
35
+
36
+
37
+ def normalize_usage(value: Any) -> TokenUsage:
38
+ payload = _to_mapping(value)
39
+ if payload is None:
40
+ return TokenUsage()
41
+
42
+ prompt_tokens = _int_field(payload.get("prompt_tokens"))
43
+ completion_tokens = _int_field(payload.get("completion_tokens"))
44
+ total_tokens = _int_field(payload.get("total_tokens"))
45
+
46
+ sdk_input_tokens = _int_field(payload.get("input_tokens"))
47
+ sdk_output_tokens = _int_field(payload.get("output_tokens"))
48
+ if prompt_tokens == 0:
49
+ prompt_tokens = sdk_input_tokens
50
+ if completion_tokens == 0:
51
+ completion_tokens = sdk_output_tokens
52
+ if total_tokens == 0:
53
+ total_tokens = prompt_tokens + completion_tokens
54
+
55
+ prompt_details = _to_mapping(payload.get("prompt_tokens_details")) or {}
56
+ completion_details = _to_mapping(payload.get("completion_tokens_details")) or {}
57
+ input_details = _to_mapping(payload.get("input_tokens_details")) or {}
58
+ output_details = _to_mapping(payload.get("output_tokens_details")) or {}
59
+
60
+ prompt_cache_hit_tokens = _first_int(
61
+ payload.get("prompt_cache_hit_tokens"),
62
+ prompt_details.get("cached_tokens"),
63
+ input_details.get("cached_tokens"),
64
+ )
65
+ prompt_cache_miss_tokens = _int_field(payload.get("prompt_cache_miss_tokens"))
66
+ if prompt_cache_miss_tokens == 0 and prompt_tokens and prompt_cache_hit_tokens:
67
+ prompt_cache_miss_tokens = max(prompt_tokens - prompt_cache_hit_tokens, 0)
68
+ reasoning_tokens = _first_int(
69
+ completion_details.get("reasoning_tokens"),
70
+ output_details.get("reasoning_tokens"),
71
+ )
72
+
73
+ request_entries = _request_usage_entries(payload.get("request_usage_entries"))
74
+ if not request_entries and payload:
75
+ entry = {
76
+ "prompt_tokens": prompt_tokens,
77
+ "completion_tokens": completion_tokens,
78
+ "total_tokens": total_tokens,
79
+ }
80
+ if prompt_cache_hit_tokens:
81
+ entry["prompt_cache_hit_tokens"] = prompt_cache_hit_tokens
82
+ if prompt_cache_miss_tokens:
83
+ entry["prompt_cache_miss_tokens"] = prompt_cache_miss_tokens
84
+ if reasoning_tokens:
85
+ entry["reasoning_tokens"] = reasoning_tokens
86
+ if any(entry.values()):
87
+ request_entries = [entry]
88
+ requests = _int_field(payload.get("requests"))
89
+ if requests == 0 and request_entries:
90
+ requests = len(request_entries)
91
+
92
+ return TokenUsage(
93
+ prompt_tokens=prompt_tokens,
94
+ completion_tokens=completion_tokens,
95
+ total_tokens=total_tokens,
96
+ prompt_cache_hit_tokens=prompt_cache_hit_tokens,
97
+ prompt_cache_miss_tokens=prompt_cache_miss_tokens,
98
+ reasoning_tokens=reasoning_tokens,
99
+ requests=requests,
100
+ request_usage_entries=request_entries,
101
+ )
102
+
103
+
104
+ def merge_usage(*items: TokenUsage | Mapping[str, Any] | None) -> TokenUsage:
105
+ total = TokenUsage()
106
+ for item in items:
107
+ usage = item if isinstance(item, TokenUsage) else normalize_usage(item)
108
+ if not usage.known:
109
+ continue
110
+ total = TokenUsage(
111
+ prompt_tokens=total.prompt_tokens + usage.prompt_tokens,
112
+ completion_tokens=total.completion_tokens + usage.completion_tokens,
113
+ total_tokens=total.total_tokens + usage.total_tokens,
114
+ prompt_cache_hit_tokens=total.prompt_cache_hit_tokens + usage.prompt_cache_hit_tokens,
115
+ prompt_cache_miss_tokens=total.prompt_cache_miss_tokens + usage.prompt_cache_miss_tokens,
116
+ reasoning_tokens=total.reasoning_tokens + usage.reasoning_tokens,
117
+ requests=total.requests + usage.requests,
118
+ request_usage_entries=[
119
+ *total.request_usage_entries,
120
+ *usage.request_usage_entries,
121
+ ],
122
+ )
123
+ return total
124
+
125
+
126
+ def usage_from_run_result(result: Any) -> TokenUsage:
127
+ context = getattr(result, "context_wrapper", None)
128
+ usage = getattr(context, "usage", None)
129
+ return normalize_usage(usage)
130
+
131
+
132
+ def format_usage_line(usage: TokenUsage | Mapping[str, Any] | None) -> str:
133
+ normalized = usage if isinstance(usage, TokenUsage) else normalize_usage(usage)
134
+ if not normalized.known:
135
+ return "usage=unknown"
136
+ parts = [f"context input {normalized.prompt_tokens:,}"]
137
+ cache_tokens = normalized.prompt_cache_hit_tokens + normalized.prompt_cache_miss_tokens
138
+ if cache_tokens:
139
+ cache_hit_rate = normalized.prompt_cache_hit_tokens / cache_tokens * 100
140
+ parts.append(f"fresh input {normalized.prompt_cache_miss_tokens:,}")
141
+ parts.append(
142
+ f"cached input {normalized.prompt_cache_hit_tokens:,} "
143
+ f"({cache_hit_rate:.1f}% hit)"
144
+ )
145
+ parts.append(f"output {normalized.completion_tokens:,}")
146
+ if normalized.reasoning_tokens:
147
+ parts.append(f"reasoning {normalized.reasoning_tokens:,}")
148
+ parts.append(f"total {normalized.total_tokens:,}")
149
+ return " · ".join(parts)
150
+
151
+
152
+ def _to_mapping(value: Any) -> Mapping[str, Any] | None:
153
+ if isinstance(value, Mapping):
154
+ return value
155
+ model_dump = getattr(value, "model_dump", None)
156
+ if callable(model_dump):
157
+ dumped = model_dump()
158
+ return dumped if isinstance(dumped, Mapping) else None
159
+ if hasattr(value, "__dict__"):
160
+ return dict(value.__dict__)
161
+ return None
162
+
163
+
164
+ def _int_field(value: Any) -> int:
165
+ if isinstance(value, bool):
166
+ return 0
167
+ if isinstance(value, int):
168
+ return max(value, 0)
169
+ if isinstance(value, float) and value.is_integer():
170
+ return max(int(value), 0)
171
+ return 0
172
+
173
+
174
+ def _first_int(*values: Any) -> int:
175
+ for value in values:
176
+ number = _int_field(value)
177
+ if number:
178
+ return number
179
+ return 0
180
+
181
+
182
+ def _request_usage_entries(value: Any) -> list[dict[str, Any]]:
183
+ if not isinstance(value, list):
184
+ return []
185
+ entries: list[dict[str, Any]] = []
186
+ for item in value:
187
+ normalized = normalize_usage(item)
188
+ if normalized.known:
189
+ payload = normalized.to_dict()
190
+ payload.pop("request_usage_entries", None)
191
+ entries.append(payload)
192
+ return entries
@@ -0,0 +1,15 @@
1
+ from .debug_logger import debug_log_path, log_debug_event, normalize_error
2
+ from .error_logger import error_log_path, log_api_error, mask_sensitive
3
+ from .notify import build_notify_env, format_duration_seconds, launch_notify_script
4
+
5
+ __all__ = [
6
+ "build_notify_env",
7
+ "debug_log_path",
8
+ "error_log_path",
9
+ "format_duration_seconds",
10
+ "launch_notify_script",
11
+ "log_api_error",
12
+ "log_debug_event",
13
+ "mask_sensitive",
14
+ "normalize_error",
15
+ ]
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import traceback
4
+ from collections.abc import Mapping
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from . import json as json_utils
9
+
10
+
11
+ def debug_log_path(deepy_home: Path | None = None) -> Path:
12
+ home = deepy_home or Path.home() / ".deepy"
13
+ return home / "logs" / "debug.log"
14
+
15
+
16
+ def normalize_error(error: BaseException | object) -> dict[str, str]:
17
+ if isinstance(error, BaseException):
18
+ payload = {
19
+ "name": error.__class__.__name__,
20
+ "message": str(error),
21
+ }
22
+ stack = "".join(traceback.format_exception(type(error), error, error.__traceback__)).strip()
23
+ if stack:
24
+ payload["stack"] = stack
25
+ return payload
26
+ return {"name": "UnknownError", "message": str(error)}
27
+
28
+
29
+ def log_debug_event(entry: Mapping[str, Any], *, deepy_home: Path | None = None) -> None:
30
+ try:
31
+ path = debug_log_path(deepy_home)
32
+ path.parent.mkdir(parents=True, exist_ok=True)
33
+ with path.open("a", encoding="utf-8") as fh:
34
+ fh.write(json_utils.dumps(_to_serializable(entry)))
35
+ fh.write("\n")
36
+ except Exception:
37
+ return
38
+
39
+
40
+ def _to_serializable(value: Any, seen: set[int] | None = None) -> Any:
41
+ seen = seen or set()
42
+ if isinstance(value, BaseException):
43
+ return normalize_error(value)
44
+ if isinstance(value, Path):
45
+ return str(value)
46
+ if isinstance(value, dict):
47
+ marker = id(value)
48
+ if marker in seen:
49
+ return "[Circular]"
50
+ seen.add(marker)
51
+ return {str(key): _to_serializable(item, seen) for key, item in value.items()}
52
+ if isinstance(value, list | tuple | set):
53
+ marker = id(value)
54
+ if marker in seen:
55
+ return "[Circular]"
56
+ seen.add(marker)
57
+ return [_to_serializable(item, seen) for item in value]
58
+ try:
59
+ json_utils.dumps(value)
60
+ except TypeError:
61
+ return str(value)
62
+ return value