deepy-cli 0.2.10__tar.gz → 0.2.12__tar.gz

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 (101) hide show
  1. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/PKG-INFO +1 -1
  2. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/pyproject.toml +1 -1
  3. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/__init__.py +1 -1
  4. deepy_cli-0.2.12/src/deepy/data/tools/apply_patch.md +68 -0
  5. deepy_cli-0.2.12/src/deepy/data/tools/edit_text.md +21 -0
  6. deepy_cli-0.2.12/src/deepy/data/tools/read_file.md +16 -0
  7. deepy_cli-0.2.12/src/deepy/data/tools/write_file.md +13 -0
  8. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/prompts/system.py +3 -3
  9. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/prompts/tool_docs.py +4 -2
  10. deepy_cli-0.2.12/src/deepy/session_cost.py +183 -0
  11. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/sessions/jsonl.py +36 -0
  12. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/tools/agents.py +193 -79
  13. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/tools/builtin.py +1465 -76
  14. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/tools/file_state.py +61 -6
  15. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/tui/app.py +58 -0
  16. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/tui/diff.py +66 -14
  17. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/exit_summary.py +13 -0
  18. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/message_view.py +92 -10
  19. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/terminal.py +156 -46
  20. deepy_cli-0.2.10/src/deepy/data/tools/edit.md +0 -17
  21. deepy_cli-0.2.10/src/deepy/data/tools/modify.md +0 -26
  22. deepy_cli-0.2.10/src/deepy/data/tools/read.md +0 -8
  23. deepy_cli-0.2.10/src/deepy/data/tools/write.md +0 -16
  24. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/README.md +0 -0
  25. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/__main__.py +0 -0
  26. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/cli.py +0 -0
  27. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/config/__init__.py +0 -0
  28. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/config/settings.py +0 -0
  29. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/data/__init__.py +0 -0
  30. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/data/skills/skill-creator/SKILL.md +0 -0
  31. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/data/skills/skill-installer/SKILL.md +0 -0
  32. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/data/tools/AskUserQuestion.md +0 -0
  33. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/data/tools/WebFetch.md +0 -0
  34. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/data/tools/WebSearch.md +0 -0
  35. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/data/tools/__init__.py +0 -0
  36. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/data/tools/shell.md +0 -0
  37. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/data/tools/todo_write.md +0 -0
  38. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/errors.py +0 -0
  39. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/input_suggestions.py +0 -0
  40. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/llm/__init__.py +0 -0
  41. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/llm/agent.py +0 -0
  42. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/llm/compaction.py +0 -0
  43. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/llm/context.py +0 -0
  44. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/llm/events.py +0 -0
  45. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/llm/model_capabilities.py +0 -0
  46. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/llm/provider.py +0 -0
  47. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/llm/replay.py +0 -0
  48. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/llm/runner.py +0 -0
  49. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/llm/thinking.py +0 -0
  50. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/mcp.py +0 -0
  51. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/prompts/__init__.py +0 -0
  52. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/prompts/compact.py +0 -0
  53. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/prompts/init_agents.py +0 -0
  54. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/prompts/rules.py +0 -0
  55. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/prompts/runtime_context.py +0 -0
  56. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/sessions/__init__.py +0 -0
  57. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/sessions/manager.py +0 -0
  58. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/skill_market.py +0 -0
  59. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/skills.py +0 -0
  60. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/status.py +0 -0
  61. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/todos.py +0 -0
  62. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/tools/__init__.py +0 -0
  63. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/tools/result.py +0 -0
  64. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/tools/shell_output.py +0 -0
  65. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/tools/shell_utils.py +0 -0
  66. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/tui/__init__.py +0 -0
  67. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/tui/commands.py +0 -0
  68. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/tui/compat.py +0 -0
  69. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/tui/runner.py +0 -0
  70. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/tui/screens.py +0 -0
  71. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/tui/state.py +0 -0
  72. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/tui/widgets.py +0 -0
  73. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/types/__init__.py +0 -0
  74. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/types/sdk.py +0 -0
  75. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/types/tool_payloads.py +0 -0
  76. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/__init__.py +0 -0
  77. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/app.py +0 -0
  78. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/ask_user_question.py +0 -0
  79. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/file_mentions.py +0 -0
  80. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/loading_text.py +0 -0
  81. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/local_command.py +0 -0
  82. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/markdown.py +0 -0
  83. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/model_picker.py +0 -0
  84. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/prompt_buffer.py +0 -0
  85. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/prompt_input.py +0 -0
  86. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/session_list.py +0 -0
  87. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/session_picker.py +0 -0
  88. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/skill_picker.py +0 -0
  89. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/slash_commands.py +0 -0
  90. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/status_footer.py +0 -0
  91. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/styles.py +0 -0
  92. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/theme_picker.py +0 -0
  93. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/thinking_state.py +0 -0
  94. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/ui/welcome.py +0 -0
  95. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/update_check.py +0 -0
  96. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/usage.py +0 -0
  97. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/utils/__init__.py +0 -0
  98. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/utils/debug_logger.py +0 -0
  99. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/utils/error_logger.py +0 -0
  100. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/utils/json.py +0 -0
  101. {deepy_cli-0.2.10 → deepy_cli-0.2.12}/src/deepy/utils/notify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: deepy-cli
3
- Version: 0.2.10
3
+ Version: 0.2.12
4
4
  Summary: Deepy - Vibe coding for DeepSeek models in your terminal
5
5
  Keywords: deepseek,coding-agent,terminal,cli,agents
6
6
  Author: kirineko
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepy-cli"
3
- version = "0.2.10"
3
+ version = "0.2.12"
4
4
  description = "Deepy - Vibe coding for DeepSeek models in your terminal"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.2.10"
3
+ __version__ = "0.2.12"
4
4
 
5
5
 
6
6
  def main() -> None:
@@ -0,0 +1,68 @@
1
+ ## apply_patch
2
+
3
+ Batch structured file operations in one call.
4
+
5
+ Args: `operations`.
6
+
7
+ Use this when a change has multiple edits in one file, touches multiple files,
8
+ creates/deletes/moves files, replaces a larger block, or needs one all-or-nothing
9
+ preflight before writing. Use `edit_text` only for one small single-file exact
10
+ edit where `old_string` and `new_string` are straightforward.
11
+
12
+ Each operation is an object with a `type` and the fields relevant to that type.
13
+ Set unrelated nullable fields to `null` when required by the schema.
14
+
15
+ Supported operation types:
16
+
17
+ - `create_file`: create a new text file with `file_path` and `content`.
18
+ - `replace_file`: explicitly replace a whole existing file with `file_path`,
19
+ `content`, `overwrite=true`, and either `snapshot_id` or `expected_hash`.
20
+ - `delete_file`: delete `file_path`.
21
+ - `move_file`: move `file_path` to `destination_path`.
22
+ - `replace_block`: replace exact `old_text` with `new_text`.
23
+ - `insert_before`: insert `content` before exact `anchor`.
24
+ - `insert_after`: insert `content` after exact `anchor`.
25
+ - `replace_all`: replace every exact `old_text` match with `new_text`.
26
+
27
+ Example:
28
+
29
+ ```json
30
+ {
31
+ "operations": [
32
+ {
33
+ "type": "replace_block",
34
+ "file_path": "portfolio/index.html",
35
+ "old_text": "<p>Old bio</p>",
36
+ "new_text": "<p>New bio</p>",
37
+ "expected_occurrences": 1,
38
+ "destination_path": null,
39
+ "content": null,
40
+ "anchor": null,
41
+ "replace_all": null,
42
+ "overwrite": null,
43
+ "snapshot_id": null,
44
+ "expected_hash": null
45
+ },
46
+ {
47
+ "type": "insert_after",
48
+ "file_path": "portfolio/styles.css",
49
+ "anchor": ".about {\\n display: grid;\\n}\\n",
50
+ "content": "\\n.about-tags {\\n display: flex;\\n}\\n",
51
+ "expected_occurrences": 1,
52
+ "destination_path": null,
53
+ "old_text": null,
54
+ "new_text": null,
55
+ "replace_all": null,
56
+ "overwrite": null,
57
+ "snapshot_id": null,
58
+ "expected_hash": null
59
+ }
60
+ ]
61
+ }
62
+ ```
63
+
64
+ Deepy preflights all operations before committing file side effects, preserves
65
+ existing encodings and line endings on updates, writes new text files as UTF-8
66
+ without BOM, and returns per-operation, changed-file, diff, and diff-preview
67
+ metadata. Exact text and anchor operations reject absent, ambiguous, no-op, and
68
+ unexpected-count matches with structured diagnostics.
@@ -0,0 +1,21 @@
1
+ ## edit_text
2
+
3
+ Make a small exact/string edit to an existing text file.
4
+
5
+ Args: `file_path`, `old_string`, `new_string`, `replace_all`,
6
+ `expected_occurrences`, optional `snippet_id`.
7
+
8
+ Use this for focused edits where the current text is known. Prefer
9
+ `expected_occurrences` as a safety check, especially with `replace_all`. If a
10
+ partial `read_file` returned a `snippet_id`, pass it only when you intentionally
11
+ want to restrict the replacement to that snippet. For ordinary single-file exact
12
+ edits, pass `file_path` and omit `snippet_id`; Deepy can internally promote a
13
+ fresh partial read to a full-file exact edit when needed. Do not pass
14
+ `snapshot_id` as `snippet_id`; snapshots are for stale protection, while
15
+ snippets are only returned by partial reads.
16
+
17
+ Deepy preserves the existing file encoding and line endings, rejects stale files,
18
+ rejects no-op edits, and returns structured error metadata for missing,
19
+ ambiguous, or count-mismatched replacements. Use `apply_patch` when there are
20
+ multiple edits in one file, multiple files, create/delete/move operations, or a
21
+ larger block replacement.
@@ -0,0 +1,16 @@
1
+ ## read_file
2
+
3
+ Read files or list directories before changes.
4
+
5
+ Args: `file_path`, optional `offset`, optional `limit`, optional `pages`.
6
+
7
+ Text output includes line numbers. Full text reads record a managed snapshot with
8
+ encoding, line-ending, snapshot id, and content hash metadata for later
9
+ `edit_text`, `write_file`, or `apply_patch` calls. Partial reads return snippet
10
+ metadata that can scope later `edit_text` calls but do not authorize unrestricted
11
+ whole-file replacement. For normal single-file exact edits after a partial read,
12
+ prefer `edit_text` with `file_path` and no `snippet_id`; use the snippet only
13
+ when you need to constrain the replacement to that line range.
14
+
15
+ Non-text files such as images, notebooks, and PDFs may return descriptive
16
+ metadata, but they are not tracked for text mutation.
@@ -0,0 +1,13 @@
1
+ ## write_file
2
+
3
+ Create a new text file or explicitly replace a whole file.
4
+
5
+ Args: `file_path`, `content`, `overwrite`, optional `snapshot_id`, optional
6
+ `expected_hash`.
7
+
8
+ For new files, Deepy writes UTF-8 without BOM by default. For existing files,
9
+ whole-file replacement requires `overwrite=true` and a fresh `snapshot_id` or
10
+ `expected_hash` from `read_file`; this prevents accidental stale rewrites.
11
+
12
+ Prefer `edit_text` for small targeted edits and `apply_patch` for structured or
13
+ multi-file edits.
@@ -43,10 +43,10 @@ def build_system_prompt(
43
43
  return f"""You are Deepy, a terminal coding agent in the user's project.
44
44
 
45
45
  Core rules:
46
- - Work in the repo with tools: inspect, modify, test, verify.
46
+ - Work in the repo with tools: inspect, edit, test, verify.
47
47
  - Preserve user changes. Prefer small, verifiable edits.
48
- - Read existing files when you need context; exact `modify` edits can establish the managed snapshot internally.
49
- - Use `modify` for file changes: `content` only creates new files; existing files use `old_string`/`new_string`.
48
+ - Read existing files when you need context; exact `edit_text` edits can establish the managed snapshot internally.
49
+ - Use `edit_text` for one small single-file exact edit. Use structured `apply_patch.operations` when a change has multiple edits in one file, touches multiple files, creates/deletes/moves files, or replaces a larger block. Use `write_file` for new files or explicit whole-file replacement.
50
50
  - After project generators create scaffold files, read and edit the generated block instead of replacing the file.
51
51
  - Run shell commands using the Runtime context's command dialect and path style: `powershell` -> PowerShell with Windows paths; `cmd` -> cmd; `posix` -> POSIX shell.
52
52
  - Match visible thinking/reasoning language to the user's latest natural language. If the user asks in Chinese, you MUST write visible thinking/reasoning in Chinese unless they explicitly request another language. Do not switch visible thinking/reasoning to English for Chinese requests.
@@ -5,8 +5,10 @@ from importlib import resources
5
5
 
6
6
  TOOL_DOC_FILES = (
7
7
  "shell.md",
8
- "read.md",
9
- "modify.md",
8
+ "read_file.md",
9
+ "edit_text.md",
10
+ "write_file.md",
11
+ "apply_patch.md",
10
12
  "AskUserQuestion.md",
11
13
  "WebSearch.md",
12
14
  "WebFetch.md",
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ import urllib.parse
4
+ from decimal import Decimal, InvalidOperation
5
+ from typing import Any, Mapping
6
+
7
+ from deepy.config import Settings
8
+
9
+
10
+ def should_track_session_cost(settings: Settings) -> bool:
11
+ if not settings.model.api_key:
12
+ return False
13
+ parsed = urllib.parse.urlparse(settings.model.base_url)
14
+ return parsed.scheme in {"http", "https"} and parsed.hostname == "api.deepseek.com"
15
+
16
+
17
+ def balance_snapshot_to_dict(balance: Any, *, captured_at_ms: int) -> dict[str, Any]:
18
+ unavailable_reason = _string_or_none(_field(balance, "unavailable_reason"))
19
+ if unavailable_reason:
20
+ return {
21
+ "capturedAt": captured_at_ms,
22
+ "unavailableReason": unavailable_reason,
23
+ }
24
+
25
+ infos: list[dict[str, str]] = []
26
+ for item in _field(balance, "balance_infos") or ():
27
+ currency = _string_or_none(_field(item, "currency"))
28
+ total = _string_or_none(_field(item, "total_balance"))
29
+ granted = _string_or_none(_field(item, "granted_balance"))
30
+ topped_up = _string_or_none(_field(item, "topped_up_balance"))
31
+ if None in (currency, total, granted, topped_up):
32
+ return {
33
+ "capturedAt": captured_at_ms,
34
+ "unavailableReason": "invalid balance snapshot",
35
+ }
36
+ infos.append(
37
+ {
38
+ "currency": currency or "",
39
+ "totalBalance": total or "",
40
+ "grantedBalance": granted or "",
41
+ "toppedUpBalance": topped_up or "",
42
+ }
43
+ )
44
+ is_available = _field(balance, "is_available")
45
+ return {
46
+ "capturedAt": captured_at_ms,
47
+ "isAvailable": is_available if isinstance(is_available, bool) else None,
48
+ "balanceInfos": infos,
49
+ }
50
+
51
+
52
+ def start_session_cost(snapshot: Mapping[str, Any]) -> dict[str, Any]:
53
+ cost: dict[str, Any] = {
54
+ "attempted": True,
55
+ "start": dict(snapshot),
56
+ }
57
+ if reason := _snapshot_unavailable_reason(snapshot):
58
+ cost["unavailableReason"] = f"start {reason}"
59
+ return cost
60
+
61
+
62
+ def complete_session_cost(
63
+ existing: Mapping[str, Any] | None,
64
+ end_snapshot: Mapping[str, Any],
65
+ ) -> dict[str, Any]:
66
+ cost: dict[str, Any] = dict(existing or {})
67
+ cost["attempted"] = True
68
+ cost["end"] = dict(end_snapshot)
69
+ start = cost.get("start")
70
+ if not isinstance(start, Mapping):
71
+ cost["amounts"] = []
72
+ cost["unavailableReason"] = "start snapshot missing"
73
+ return cost
74
+ amounts, reason = compute_session_cost_amounts(start, end_snapshot)
75
+ cost["amounts"] = amounts
76
+ if reason:
77
+ cost["unavailableReason"] = reason
78
+ else:
79
+ cost.pop("unavailableReason", None)
80
+ return cost
81
+
82
+
83
+ def compute_session_cost_amounts(
84
+ start_snapshot: Mapping[str, Any],
85
+ end_snapshot: Mapping[str, Any],
86
+ ) -> tuple[list[dict[str, str]], str | None]:
87
+ if reason := _snapshot_unavailable_reason(start_snapshot):
88
+ return [], f"start {reason}"
89
+ if reason := _snapshot_unavailable_reason(end_snapshot):
90
+ return [], f"end {reason}"
91
+
92
+ start_infos = _snapshot_infos_by_currency(start_snapshot)
93
+ end_infos = _snapshot_infos_by_currency(end_snapshot)
94
+ if start_infos is None or end_infos is None:
95
+ return [], "invalid balance snapshot"
96
+ shared = [currency for currency in start_infos if currency in end_infos]
97
+ if not shared:
98
+ return [], "currency mismatch"
99
+
100
+ amounts: list[dict[str, str]] = []
101
+ for currency in shared:
102
+ start_total_text = start_infos[currency]
103
+ end_total_text = end_infos[currency]
104
+ start_total = _decimal(start_total_text)
105
+ end_total = _decimal(end_total_text)
106
+ if start_total is None or end_total is None:
107
+ return [], "invalid balance amount"
108
+ spent = start_total - end_total
109
+ if spent > 0:
110
+ amounts.append(
111
+ {
112
+ "currency": currency,
113
+ "startTotal": start_total_text,
114
+ "endTotal": end_total_text,
115
+ "spent": _format_decimal(spent),
116
+ }
117
+ )
118
+ if not amounts:
119
+ return [], "no measurable spend"
120
+ return amounts, None
121
+
122
+
123
+ def format_session_cost(cost: Any) -> str | None:
124
+ if not isinstance(cost, Mapping) or not cost.get("attempted"):
125
+ return None
126
+ amounts = cost.get("amounts")
127
+ if isinstance(amounts, list) and amounts:
128
+ parts: list[str] = []
129
+ for item in amounts:
130
+ if not isinstance(item, Mapping):
131
+ continue
132
+ currency = _string_or_none(item.get("currency"))
133
+ spent = _string_or_none(item.get("spent"))
134
+ if currency and spent:
135
+ parts.append(f"{currency} {spent}")
136
+ if parts:
137
+ return f"{', '.join(parts)} (DeepSeek balance delta)"
138
+ reason = _string_or_none(cost.get("unavailableReason")) or "unknown"
139
+ return f"unavailable ({reason})"
140
+
141
+
142
+ def _field(value: Any, name: str) -> Any:
143
+ if isinstance(value, Mapping):
144
+ return value.get(name)
145
+ return getattr(value, name, None)
146
+
147
+
148
+ def _string_or_none(value: Any) -> str | None:
149
+ return value if isinstance(value, str) and value else None
150
+
151
+
152
+ def _snapshot_unavailable_reason(snapshot: Mapping[str, Any]) -> str | None:
153
+ return _string_or_none(snapshot.get("unavailableReason"))
154
+
155
+
156
+ def _snapshot_infos_by_currency(snapshot: Mapping[str, Any]) -> dict[str, str] | None:
157
+ raw_infos = snapshot.get("balanceInfos")
158
+ if not isinstance(raw_infos, list):
159
+ return None
160
+ infos: dict[str, str] = {}
161
+ for item in raw_infos:
162
+ if not isinstance(item, Mapping):
163
+ return None
164
+ currency = _string_or_none(item.get("currency"))
165
+ total = _string_or_none(item.get("totalBalance"))
166
+ if not currency or total is None:
167
+ return None
168
+ infos[currency] = total
169
+ return infos
170
+
171
+
172
+ def _decimal(value: str) -> Decimal | None:
173
+ try:
174
+ return Decimal(value)
175
+ except (InvalidOperation, ValueError):
176
+ return None
177
+
178
+
179
+ def _format_decimal(value: Decimal) -> str:
180
+ text = format(value.normalize(), "f")
181
+ if "." not in text:
182
+ return text
183
+ return text.rstrip("0").rstrip(".") or "0"
@@ -40,6 +40,7 @@ class SessionEntry:
40
40
  pending_tokens: int = 0
41
41
  last_usage_record_count: int | None = None
42
42
  todo_state: list[dict[str, str]] | None = None
43
+ session_cost: dict[str, Any] | None = None
43
44
 
44
45
 
45
46
  def project_code(project_root: Path) -> str:
@@ -160,6 +161,7 @@ class DeepyJsonlSession:
160
161
  pending_tokens=0,
161
162
  last_usage_record_count=0,
162
163
  todo_state=[],
164
+ session_cost=None,
163
165
  )
164
166
 
165
167
  def record_usage(self, usage: TokenUsage | dict[str, Any] | None) -> None:
@@ -207,6 +209,32 @@ class DeepyJsonlSession:
207
209
  ) + max(elapsed_ms, 0)
208
210
  self._touch_index(input_suggestion_usage=accumulated)
209
211
 
212
+ def record_session_cost_start(self, snapshot: dict[str, Any]) -> None:
213
+ from deepy.session_cost import start_session_cost
214
+
215
+ previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
216
+ previous_cost = previous.get("sessionCost") if previous else None
217
+ if isinstance(previous_cost, dict) and isinstance(previous_cost.get("start"), dict):
218
+ return
219
+ self._touch_index(session_cost=start_session_cost(snapshot))
220
+
221
+ def record_session_cost_end(self, snapshot: dict[str, Any]) -> None:
222
+ from deepy.session_cost import complete_session_cost
223
+
224
+ previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
225
+ previous_cost = previous.get("sessionCost") if previous else None
226
+ self._touch_index(
227
+ session_cost=complete_session_cost(
228
+ previous_cost if isinstance(previous_cost, dict) else None,
229
+ snapshot,
230
+ )
231
+ )
232
+
233
+ def session_cost(self) -> dict[str, Any] | None:
234
+ previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
235
+ cost = previous.get("sessionCost") if previous else None
236
+ return cost if isinstance(cost, dict) else None
237
+
210
238
  def context_token_state(
211
239
  self,
212
240
  records: list[dict[str, Any]] | None = None,
@@ -361,6 +389,7 @@ class DeepyJsonlSession:
361
389
  last_usage_record_count: int | None = None,
362
390
  todo_state: object = _UNSET,
363
391
  input_suggestion_usage: dict[str, Any] | None = None,
392
+ session_cost: object = _UNSET,
364
393
  ) -> None:
365
394
  self.path.parent.mkdir(parents=True, exist_ok=True)
366
395
  index_path = self.path.parent / "sessions-index.json"
@@ -432,6 +461,11 @@ class DeepyJsonlSession:
432
461
  ),
433
462
  **({"processes": previous["processes"]} if "processes" in previous else {}),
434
463
  }
464
+ if session_cost is _UNSET:
465
+ if isinstance(previous.get("sessionCost"), dict):
466
+ entry["sessionCost"] = previous["sessionCost"]
467
+ elif isinstance(session_cost, dict):
468
+ entry["sessionCost"] = session_cost
435
469
  if todo_state is _UNSET:
436
470
  if "todoState" in previous:
437
471
  entry["todoState"] = previous["todoState"]
@@ -498,6 +532,7 @@ def list_session_entries(project_root: Path, deepy_home: Path | None = None) ->
498
532
  path = f"{session_id}.jsonl"
499
533
  usage = item.get("usage")
500
534
  input_suggestion_usage = item.get("inputSuggestionUsage")
535
+ session_cost = item.get("sessionCost")
501
536
  entries.append(
502
537
  SessionEntry(
503
538
  id=session_id,
@@ -515,6 +550,7 @@ def list_session_entries(project_root: Path, deepy_home: Path | None = None) ->
515
550
  pending_tokens=_coerce_int(item.get("pendingTokens"), 0),
516
551
  last_usage_record_count=_optional_int(item.get("lastUsageRecordCount")),
517
552
  todo_state=normalize_persisted_todo_state(item.get("todoState")),
553
+ session_cost=session_cost if isinstance(session_cost, dict) else None,
518
554
  )
519
555
  )
520
556
  return entries