deepy-cli 0.2.0__tar.gz → 0.2.2__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 (84) hide show
  1. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/PKG-INFO +3 -3
  2. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/README.md +2 -2
  3. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/pyproject.toml +2 -9
  4. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/__init__.py +1 -1
  5. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/cli.py +3 -3
  6. deepy_cli-0.2.2/src/deepy/data/tools/modify.md +26 -0
  7. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/llm/agent.py +5 -1
  8. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/llm/context.py +8 -8
  9. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/llm/provider.py +3 -2
  10. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/llm/replay.py +2 -2
  11. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/llm/runner.py +2 -2
  12. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/mcp.py +8 -3
  13. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/prompts/system.py +1 -1
  14. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/skills.py +1 -1
  15. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/tools/agents.py +7 -3
  16. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/tools/builtin.py +63 -43
  17. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/tools/file_state.py +16 -0
  18. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/tools/result.py +1 -1
  19. deepy_cli-0.2.2/src/deepy/types/__init__.py +2 -0
  20. deepy_cli-0.2.2/src/deepy/types/sdk.py +12 -0
  21. deepy_cli-0.2.2/src/deepy/types/tool_payloads.py +15 -0
  22. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/exit_summary.py +2 -1
  23. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/local_command.py +9 -3
  24. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/message_view.py +27 -15
  25. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/prompt_input.py +7 -6
  26. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/skill_picker.py +150 -1
  27. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/terminal.py +76 -23
  28. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/welcome.py +2 -1
  29. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/utils/json.py +5 -2
  30. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/utils/notify.py +2 -2
  31. deepy_cli-0.2.0/src/deepy/data/tools/modify.md +0 -22
  32. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/__main__.py +0 -0
  33. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/config/__init__.py +0 -0
  34. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/config/settings.py +0 -0
  35. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/data/__init__.py +0 -0
  36. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/data/skills/skill-creator/SKILL.md +0 -0
  37. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/data/skills/skill-installer/SKILL.md +0 -0
  38. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/data/tools/AskUserQuestion.md +0 -0
  39. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/data/tools/WebFetch.md +0 -0
  40. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/data/tools/WebSearch.md +0 -0
  41. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/data/tools/__init__.py +0 -0
  42. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/data/tools/edit.md +0 -0
  43. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/data/tools/read.md +0 -0
  44. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/data/tools/shell.md +0 -0
  45. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/data/tools/write.md +0 -0
  46. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/errors.py +0 -0
  47. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/llm/__init__.py +0 -0
  48. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/llm/compaction.py +0 -0
  49. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/llm/events.py +0 -0
  50. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/llm/model_capabilities.py +0 -0
  51. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/llm/thinking.py +0 -0
  52. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/prompts/__init__.py +0 -0
  53. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/prompts/compact.py +0 -0
  54. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/prompts/init_agents.py +0 -0
  55. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/prompts/rules.py +0 -0
  56. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/prompts/runtime_context.py +0 -0
  57. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/prompts/tool_docs.py +0 -0
  58. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/sessions/__init__.py +0 -0
  59. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/sessions/jsonl.py +0 -0
  60. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/sessions/manager.py +0 -0
  61. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/skill_market.py +0 -0
  62. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/status.py +0 -0
  63. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/tools/__init__.py +0 -0
  64. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/tools/shell_output.py +0 -0
  65. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/tools/shell_utils.py +0 -0
  66. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/__init__.py +0 -0
  67. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/app.py +0 -0
  68. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/ask_user_question.py +0 -0
  69. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/file_mentions.py +0 -0
  70. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/loading_text.py +0 -0
  71. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/markdown.py +0 -0
  72. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/model_picker.py +0 -0
  73. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/prompt_buffer.py +0 -0
  74. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/session_list.py +0 -0
  75. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/session_picker.py +0 -0
  76. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/slash_commands.py +0 -0
  77. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/styles.py +0 -0
  78. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/theme_picker.py +0 -0
  79. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/ui/thinking_state.py +0 -0
  80. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/update_check.py +0 -0
  81. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/usage.py +0 -0
  82. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/utils/__init__.py +0 -0
  83. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/utils/debug_logger.py +0 -0
  84. {deepy_cli-0.2.0 → deepy_cli-0.2.2}/src/deepy/utils/error_logger.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: deepy-cli
3
- Version: 0.2.0
3
+ Version: 0.2.2
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
@@ -342,7 +342,7 @@ A concise `AGENTS.md` works best:
342
342
  ## Commands
343
343
  - Test: `uv run pytest`
344
344
  - Lint: `uv run ruff check`
345
- - Type check: `uv run pyright`
345
+ - Type check: `uv run ty check src`
346
346
 
347
347
  ## Architecture
348
348
  - Keep CLI entry points thin; put reusable behavior under `src/`.
@@ -369,7 +369,7 @@ create or refresh the project root `AGENTS.md`.
369
369
  uv sync --group dev
370
370
  uv run pytest
371
371
  uv run ruff check
372
- uv run pyright
372
+ uv run ty check src
373
373
  uv build
374
374
  ```
375
375
 
@@ -313,7 +313,7 @@ A concise `AGENTS.md` works best:
313
313
  ## Commands
314
314
  - Test: `uv run pytest`
315
315
  - Lint: `uv run ruff check`
316
- - Type check: `uv run pyright`
316
+ - Type check: `uv run ty check src`
317
317
 
318
318
  ## Architecture
319
319
  - Keep CLI entry points thin; put reusable behavior under `src/`.
@@ -340,7 +340,7 @@ create or refresh the project root `AGENTS.md`.
340
340
  uv sync --group dev
341
341
  uv run pytest
342
342
  uv run ruff check
343
- uv run pyright
343
+ uv run ty check src
344
344
  uv build
345
345
  ```
346
346
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepy-cli"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Deepy - Vibe coding for DeepSeek models in your terminal"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -39,10 +39,10 @@ Issues = "https://github.com/kirineko/deepy/issues"
39
39
 
40
40
  [dependency-groups]
41
41
  dev = [
42
- "pyright>=1.1.407",
43
42
  "pytest>=8.0",
44
43
  "pytest-asyncio>=0.24",
45
44
  "ruff>=0.14",
45
+ "ty>=0.0.32",
46
46
  ]
47
47
 
48
48
  [tool.pytest.ini_options]
@@ -54,13 +54,6 @@ target-version = "py312"
54
54
  src = ["src", "tests"]
55
55
  exclude = ["dist", "reference", "spec"]
56
56
 
57
- [tool.pyright]
58
- pythonVersion = "3.12"
59
- typeCheckingMode = "off"
60
- include = ["src", "tests"]
61
- exclude = ["dist", "reference", "spec"]
62
- reportMissingTypeStubs = false
63
-
64
57
  [tool.uv.build-backend]
65
58
  module-name = "deepy"
66
59
 
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.2"
4
4
 
5
5
 
6
6
  def main() -> None:
@@ -4,7 +4,7 @@ import argparse
4
4
  import asyncio
5
5
  import sys
6
6
  from pathlib import Path
7
- from typing import Sequence
7
+ from typing import Any, Sequence
8
8
 
9
9
  import tomli_w
10
10
 
@@ -198,7 +198,7 @@ def _cmd_config_theme(args: argparse.Namespace) -> int:
198
198
  return 0
199
199
 
200
200
 
201
- def _doctor(args: argparse.Namespace) -> tuple[int, dict[str, object]]:
201
+ def _doctor(args: argparse.Namespace) -> tuple[int, dict[str, Any]]:
202
202
  settings = load_settings(args.config)
203
203
  checks: list[dict[str, object]] = []
204
204
 
@@ -251,7 +251,7 @@ def _doctor(args: argparse.Namespace) -> tuple[int, dict[str, object]]:
251
251
  }
252
252
 
253
253
 
254
- async def _doctor_live(settings: Settings) -> dict[str, object]:
254
+ async def _doctor_live(settings: Settings) -> dict[str, Any]:
255
255
  from agents import Agent, RunConfig, Runner
256
256
 
257
257
  provider = build_provider_bundle(settings)
@@ -0,0 +1,26 @@
1
+ ## modify
2
+
3
+ Create new files or edit existing files.
4
+
5
+ Use `content` only when the target file does not exist. For existing files, use
6
+ `old_string` and `new_string` for the smallest reliable replacement. You may
7
+ attempt an exact replacement directly when you already know the current text;
8
+ Deepy records the required file snapshot internally when no prior snapshot
9
+ exists. Read first when you need to inspect context. Do not rewrite an existing
10
+ scaffolded file with full content; replace the specific generated block instead.
11
+
12
+ Args for new files: `file_path`, `content`.
13
+
14
+ Args for existing files: `file_path`, `old_string`, `new_string`, optional
15
+ `replace_all`, optional `snippet_id`.
16
+
17
+ Existing-file edits must have a managed snapshot before Deepy commits changes;
18
+ `modify` can create that snapshot internally for direct exact replacements. Stale
19
+ edits are rejected. Repeated matches are rejected unless `replace_all` is true;
20
+ candidate snippets can be reused with `snippet_id`. Success includes diff
21
+ metadata.
22
+
23
+ If several `old_string` attempts fail and you know the complete desired file content,
24
+ re-read the file and use the managed whole-file replacement path. Do not delete the file
25
+ and recreate it with shell commands or here-strings; that bypasses Deepy's encoding,
26
+ newline, and stale-write protections, especially on Windows.
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
+ from typing import TYPE_CHECKING
4
5
 
5
6
  from deepy.config import Settings
6
7
  from deepy.prompts import build_system_prompt
@@ -10,6 +11,9 @@ from deepy.tools.agents import build_function_tools
10
11
 
11
12
  from .provider import ProviderBundle, build_provider_bundle
12
13
 
14
+ if TYPE_CHECKING:
15
+ from agents.mcp import MCPServer
16
+
13
17
 
14
18
  def build_deepy_agent(
15
19
  settings: Settings,
@@ -18,7 +22,7 @@ def build_deepy_agent(
18
22
  project_root: Path,
19
23
  provider: ProviderBundle | None = None,
20
24
  loaded_skills: list[SkillInfo] | None = None,
21
- mcp_servers: list[object] | None = None,
25
+ mcp_servers: list[MCPServer] | None = None,
22
26
  preferred_mcp_web_search_tools: list[str] | None = None,
23
27
  ):
24
28
  from agents import Agent
@@ -1,16 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import Callable
4
3
  from math import ceil
5
4
  from typing import Any
6
5
 
7
6
  from deepy.config import Settings
7
+ from deepy.types.sdk import SessionInputCallback
8
8
  from deepy.utils import json as json_utils
9
9
 
10
+ tiktoken: Any | None
10
11
  try:
11
- import tiktoken
12
+ import tiktoken as _tiktoken
12
13
  except Exception: # pragma: no cover - optional dependency fallback.
13
- tiktoken = None # type: ignore[assignment]
14
+ tiktoken = None
15
+ else:
16
+ tiktoken = _tiktoken
14
17
 
15
18
  _ENCODING = None
16
19
 
@@ -38,11 +41,8 @@ def estimate_tokens_for_items(items: list[dict[str, Any]]) -> int:
38
41
  return sum(estimate_tokens_for_item(item) for item in items)
39
42
 
40
43
 
41
- def build_session_input_callback(settings: Settings) -> Callable[
42
- [list[dict[str, Any]], list[dict[str, Any]]],
43
- list[dict[str, Any]],
44
- ]:
45
- def callback(history: list[dict[str, Any]], new_input: list[dict[str, Any]]) -> list[dict[str, Any]]:
44
+ def build_session_input_callback(settings: Settings) -> SessionInputCallback:
45
+ def callback(history: list[Any], new_input: list[Any]) -> list[Any]:
46
46
  return [*history, *new_input]
47
47
 
48
48
  return callback
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from dataclasses import dataclass
4
4
  from typing import Any
5
5
 
6
+ from agents import Model, ModelSettings
6
7
  from agents import OpenAIChatCompletionsModel
7
8
 
8
9
  from deepy.config import Settings
@@ -17,8 +18,8 @@ from .replay import (
17
18
  @dataclass(frozen=True)
18
19
  class ProviderBundle:
19
20
  client: object
20
- model: object
21
- model_settings: object
21
+ model: Model
22
+ model_settings: ModelSettings
22
23
 
23
24
 
24
25
  class DeepyOpenAIChatCompletionsModel(OpenAIChatCompletionsModel):
@@ -105,9 +105,9 @@ def sanitize_chat_completion_stream_event(event: Any) -> Any | None:
105
105
  if getattr(event, "type", None) == "response.completed":
106
106
  response = getattr(event, "response", None)
107
107
  output = getattr(response, "output", None)
108
- if isinstance(output, list):
108
+ if response is not None and isinstance(output, list):
109
109
  try:
110
- response.output = sanitize_model_response_output(output)
110
+ cast(Any, response).output = sanitize_model_response_output(output)
111
111
  except Exception:
112
112
  pass
113
113
  return event
@@ -130,7 +130,7 @@ async def run_prompt_once(
130
130
  input=prompt,
131
131
  max_turns=max_turns,
132
132
  run_config=run_config,
133
- session=session,
133
+ session=session, # ty: ignore[invalid-argument-type] - DeepyJsonlSession matches the SDK Session protocol at runtime.
134
134
  )
135
135
  if should_interrupt is not None:
136
136
  interrupt_task = asyncio.create_task(
@@ -349,7 +349,7 @@ def _max_turns_output(chunks: list[str], *, max_turns: int) -> str:
349
349
 
350
350
  def format_deepseek_api_error(error: Any) -> str:
351
351
  status_code = _safe_int(getattr(error, "status_code", None))
352
- status = DEEPSEEK_ERROR_CODES.get(status_code)
352
+ status = DEEPSEEK_ERROR_CODES.get(status_code) if status_code is not None else None
353
353
  title = f"DeepSeek API error {status_code}" if status_code is not None else "DeepSeek API error"
354
354
  if status is not None:
355
355
  title = f"{title}: {status.title}"
@@ -155,7 +155,12 @@ class DeepyMcpRuntime:
155
155
  await self._manager.cleanup_all()
156
156
 
157
157
  def _build_sdk_servers(self) -> list[Any]:
158
- from agents.mcp import MCPServerStdio, MCPServerStreamableHttp
158
+ from agents.mcp import (
159
+ MCPServerStdio,
160
+ MCPServerStdioParams,
161
+ MCPServerStreamableHttp,
162
+ MCPServerStreamableHttpParams,
163
+ )
159
164
 
160
165
  servers: list[Any] = []
161
166
  for definition in self.definitions:
@@ -173,7 +178,7 @@ class DeepyMcpRuntime:
173
178
  )
174
179
  continue
175
180
  if definition.transport == "stdio":
176
- params: dict[str, Any] = {"command": definition.command or ""}
181
+ params: MCPServerStdioParams = {"command": definition.command or ""}
177
182
  if definition.args:
178
183
  params["args"] = list(definition.args)
179
184
  if definition.env:
@@ -189,7 +194,7 @@ class DeepyMcpRuntime:
189
194
  ),
190
195
  )
191
196
  else:
192
- params = {"url": definition.url or ""}
197
+ params: MCPServerStreamableHttpParams = {"url": definition.url or ""}
193
198
  if definition.headers:
194
199
  params["headers"] = dict(definition.headers)
195
200
  server = MCPServerStreamableHttp(
@@ -45,7 +45,7 @@ def build_system_prompt(
45
45
  Core rules:
46
46
  - Work in the repo with tools: inspect, modify, test, verify.
47
47
  - Preserve user changes. Prefer small, verifiable edits.
48
- - Read before changing existing files.
48
+ - Read existing files when you need context; exact `modify` edits can establish the managed snapshot internally.
49
49
  - Use `modify` for file changes: `content` only creates new files; existing files use `old_string`/`new_string`.
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.
@@ -116,7 +116,7 @@ def _format_skills(skills: Iterable[SkillInfo], *, include_paths: bool) -> str:
116
116
 
117
117
 
118
118
  def _builtin_skills_root() -> Path:
119
- return Path(resources.files("deepy.data").joinpath("skills"))
119
+ return Path(str(resources.files("deepy.data").joinpath("skills")))
120
120
 
121
121
 
122
122
  def _discover_skills_root(root: Path, *, scope: str) -> list[SkillInfo]:
@@ -1,17 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any
3
+ from typing import TYPE_CHECKING, Any
4
4
 
5
5
  from deepy.utils import json as json_utils
6
6
 
7
7
  from .builtin import ToolRuntime
8
8
 
9
+ if TYPE_CHECKING:
10
+ from agents import Tool
11
+
9
12
 
10
13
  def build_function_tools(
11
14
  runtime: ToolRuntime,
12
15
  *,
13
16
  preferred_mcp_web_search_tools: list[str] | None = None,
14
- ) -> list[object]:
17
+ ) -> list[Tool]:
15
18
  from agents.tool import FunctionTool
16
19
 
17
20
  async def invoke_shell(_context: object, raw_input: str) -> str:
@@ -104,7 +107,8 @@ def build_function_tools(
104
107
  name="modify",
105
108
  description=(
106
109
  "Create new files or edit existing files. Use content only for files that do not "
107
- "exist. For existing files, read first and use old_string/new_string."
110
+ "exist. For existing files, use old_string/new_string; read first when you need "
111
+ "to inspect context."
108
112
  ),
109
113
  params_json_schema=MODIFY_SCHEMA,
110
114
  on_invoke_tool=invoke_modify,
@@ -22,6 +22,7 @@ from html.parser import HTMLParser
22
22
  from pathlib import Path
23
23
 
24
24
  from deepy.config import DEFAULT_WEB_SEARCH_SEARXNG_URL, Settings, mask_secret
25
+ from deepy.types.tool_payloads import AskUserOption, AskUserQuestion
25
26
  from deepy.utils import json as json_utils
26
27
 
27
28
  from .file_state import FileSnippet, FileState
@@ -728,7 +729,7 @@ def _decide_search_language_with_llm(query: str, settings: Settings) -> dict[str
728
729
  )
729
730
  parsed = _parse_json_response(_web_search_chat(settings, prompt))
730
731
  dominant_language = parsed.get("dominant_language")
731
- if dominant_language not in {"en", "zh"}:
732
+ if not isinstance(dominant_language, str) or dominant_language not in {"en", "zh"}:
732
733
  raise ValueError(f"Unexpected dominant language: {dominant_language}")
733
734
  reason = parsed.get("reason")
734
735
  return {
@@ -1249,7 +1250,7 @@ class ToolRuntime:
1249
1250
  )
1250
1251
  self.file_state.mark_read(target, full=False)
1251
1252
  snippet_metadata = _snippet_metadata(snippet)
1252
- metadata = {
1253
+ metadata: dict[str, object] = {
1253
1254
  "path": str(target),
1254
1255
  "kind": "file",
1255
1256
  "startLine": start + 1,
@@ -1301,7 +1302,14 @@ class ToolRuntime:
1301
1302
  "modify",
1302
1303
  "Provide content for a new file, or both old_string and new_string for an existing file.",
1303
1304
  ).to_json()
1304
- return self.edit(path, old, new, replace_all=replace_all, snippet_id=snippet_id)
1305
+ return self.edit(
1306
+ path,
1307
+ old,
1308
+ new,
1309
+ replace_all=replace_all,
1310
+ snippet_id=snippet_id,
1311
+ auto_read_if_missing_snapshot=True,
1312
+ )
1305
1313
 
1306
1314
  def write(self, path: str, content: object) -> str:
1307
1315
  name = "write"
@@ -1322,7 +1330,7 @@ class ToolRuntime:
1322
1330
  encoding = (
1323
1331
  existing_metadata.encoding
1324
1332
  if existing_metadata is not None
1325
- else _default_new_text_encoding(text_content, platform_name=self.platform_name)
1333
+ else _default_new_text_encoding()
1326
1334
  )
1327
1335
  line_endings = _detect_line_endings(old_content or text_content)
1328
1336
  normalized_content = _normalize_line_endings(text_content, line_endings)
@@ -1350,6 +1358,7 @@ class ToolRuntime:
1350
1358
  new: str,
1351
1359
  replace_all: bool = False,
1352
1360
  snippet_id: str | None = None,
1361
+ auto_read_if_missing_snapshot: bool = False,
1353
1362
  ) -> str:
1354
1363
  name = "edit"
1355
1364
  if not old:
@@ -1376,6 +1385,14 @@ class ToolRuntime:
1376
1385
  target = _resolve_in_cwd(self.cwd, path)
1377
1386
  if not target.exists():
1378
1387
  return ToolResult.error_result(name, f"File does not exist: {target}").to_json()
1388
+ auto_read_before_modify = False
1389
+ if (
1390
+ auto_read_if_missing_snapshot
1391
+ and snippet is None
1392
+ and self.file_state.snapshot_status(target) == "missing"
1393
+ ):
1394
+ self.file_state.mark_read(target)
1395
+ auto_read_before_modify = True
1379
1396
  ok, error = self.file_state.check_writable(
1380
1397
  target,
1381
1398
  require_read=True,
@@ -1453,7 +1470,7 @@ class ToolRuntime:
1453
1470
  _write_text_with_encoding(target, updated, text_metadata.encoding)
1454
1471
  self.file_state.mark_written(target)
1455
1472
  diff = _unified_diff(text, updated, path=str(target))
1456
- metadata = {
1473
+ metadata: dict[str, object] = {
1457
1474
  "path": str(target),
1458
1475
  "file_path": str(target),
1459
1476
  "occurrences": occurrences if replace_all else 1,
@@ -1464,6 +1481,8 @@ class ToolRuntime:
1464
1481
  "diff": diff,
1465
1482
  "diff_preview": diff,
1466
1483
  }
1484
+ if auto_read_before_modify:
1485
+ metadata["autoReadBeforeModify"] = True
1467
1486
  if snippet is not None:
1468
1487
  metadata["scope"] = _format_scope_metadata(target, snippet, scope, text)
1469
1488
  return ToolResult.ok_result(name, f"Edited {target}", metadata=metadata).to_json()
@@ -1542,7 +1561,6 @@ class ToolRuntime:
1542
1561
  self.cwd = final_cwd
1543
1562
  returncode = exit_code if exit_code is not None else process.returncode
1544
1563
  output, output_truncated = _truncate_output(stdout + (stderr or ""))
1545
- result = ToolResult.ok_result if returncode == 0 else ToolResult.error_result
1546
1564
  metadata = _shell_metadata(
1547
1565
  self.cwd,
1548
1566
  process_id,
@@ -1558,12 +1576,12 @@ class ToolRuntime:
1558
1576
  }
1559
1577
  )
1560
1578
  if returncode == 0:
1561
- return result(
1579
+ return ToolResult.ok_result(
1562
1580
  name,
1563
1581
  output,
1564
1582
  metadata=metadata,
1565
1583
  ).to_json()
1566
- return result(
1584
+ return ToolResult.error_result(
1567
1585
  name,
1568
1586
  f"Command exited with code {returncode}.",
1569
1587
  output=output,
@@ -1916,17 +1934,10 @@ def _python_text_encoding(encoding: str) -> str:
1916
1934
  return "utf8"
1917
1935
 
1918
1936
 
1919
- def _default_new_text_encoding(content: str, *, platform_name: str | None = None) -> str:
1920
- resolved_platform = platform_name or sys.platform
1921
- if resolved_platform.startswith("win") and _contains_non_ascii(content):
1922
- return "utf8-sig"
1937
+ def _default_new_text_encoding() -> str:
1923
1938
  return "utf8"
1924
1939
 
1925
1940
 
1926
- def _contains_non_ascii(text: str) -> bool:
1927
- return any(ord(char) > 0x7F for char in text)
1928
-
1929
-
1930
1941
  def _write_text_with_encoding(path: Path, content: str, encoding: str) -> None:
1931
1942
  path.write_bytes(content.encode(_python_text_encoding(encoding)))
1932
1943
 
@@ -1974,24 +1985,24 @@ def _format_notebook(path: Path) -> tuple[str, str | None]:
1974
1985
  cells = parsed.get("cells")
1975
1986
  lines: list[str] = []
1976
1987
  if isinstance(cells, list):
1977
- for index, cell in enumerate(cells):
1978
- if not isinstance(cell, dict):
1988
+ for index, raw_cell in enumerate(cells):
1989
+ cell = _string_key_dict(raw_cell)
1990
+ if cell is None:
1979
1991
  continue
1980
- cell_type = cell.get("cell_type") if isinstance(cell.get("cell_type"), str) else "unknown"
1992
+ raw_cell_type = cell.get("cell_type")
1993
+ cell_type = raw_cell_type if isinstance(raw_cell_type, str) else "unknown"
1981
1994
  lines.append(f"# Cell {index + 1} ({cell_type})")
1982
1995
  lines.extend(_normalize_notebook_field(cell.get("source")))
1983
1996
 
1984
1997
  outputs = cell.get("outputs")
1985
1998
  if not isinstance(outputs, list):
1986
1999
  continue
1987
- for output_index, output in enumerate(outputs):
1988
- if not isinstance(output, dict):
2000
+ for output_index, raw_output in enumerate(outputs):
2001
+ output = _string_key_dict(raw_output)
2002
+ if output is None:
1989
2003
  continue
1990
- output_type = (
1991
- output.get("output_type")
1992
- if isinstance(output.get("output_type"), str)
1993
- else "output"
1994
- )
2004
+ raw_output_type = output.get("output_type")
2005
+ output_type = raw_output_type if isinstance(raw_output_type, str) else "output"
1995
2006
  lines.append(f"# Output {output_index + 1} ({output_type})")
1996
2007
  lines.extend(_format_notebook_output(output))
1997
2008
 
@@ -2011,12 +2022,13 @@ def _normalize_notebook_field(value: object) -> list[str]:
2011
2022
  def _format_notebook_output(output: dict[str, object]) -> list[str]:
2012
2023
  lines = _normalize_notebook_field(output.get("text"))
2013
2024
  data = output.get("data")
2014
- if isinstance(data, dict):
2015
- lines.extend(_normalize_notebook_field(data.get("text/plain")))
2016
- image_png = data.get("image/png")
2025
+ data_dict = _string_key_dict(data)
2026
+ if data_dict is not None:
2027
+ lines.extend(_normalize_notebook_field(data_dict.get("text/plain")))
2028
+ image_png = data_dict.get("image/png")
2017
2029
  if isinstance(image_png, str):
2018
2030
  lines.append(f"[image/png {len(image_png)} chars]")
2019
- image_jpeg = data.get("image/jpeg")
2031
+ image_jpeg = data_dict.get("image/jpeg")
2020
2032
  if isinstance(image_jpeg, str):
2021
2033
  lines.append(f"[image/jpeg {len(image_jpeg)} chars]")
2022
2034
  traceback = output.get("traceback")
@@ -2025,6 +2037,14 @@ def _format_notebook_output(output: dict[str, object]) -> list[str]:
2025
2037
  return lines or ["[output omitted]"]
2026
2038
 
2027
2039
 
2040
+ def _string_key_dict(value: object) -> dict[str, object] | None:
2041
+ if not isinstance(value, dict):
2042
+ return None
2043
+ if not all(isinstance(key, str) for key in value):
2044
+ return None
2045
+ return {key: item for key, item in value.items() if isinstance(key, str)}
2046
+
2047
+
2028
2048
  @dataclass(frozen=True)
2029
2049
  class PageRange:
2030
2050
  start: int
@@ -2360,7 +2380,7 @@ def _now_iso() -> str:
2360
2380
  return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
2361
2381
 
2362
2382
 
2363
- def _terminate_process(process: subprocess.Popen[str]) -> None:
2383
+ def _terminate_process(process: subprocess.Popen[bytes]) -> None:
2364
2384
  try:
2365
2385
  if os.name != "nt":
2366
2386
  os.killpg(process.pid, signal.SIGKILL)
@@ -2483,13 +2503,14 @@ def _gitignore_pattern_matches(pattern: str, relative_path: str, is_dir: bool) -
2483
2503
  return any(fnmatch(part, normalized_pattern) for part in parts)
2484
2504
 
2485
2505
 
2486
- def _parse_ask_user_questions(value: object) -> tuple[list[dict[str, object]], str | None]:
2506
+ def _parse_ask_user_questions(value: object) -> tuple[list[AskUserQuestion], str | None]:
2487
2507
  if not isinstance(value, list) or not value:
2488
2508
  return [], '"questions" must be a non-empty array.'
2489
2509
 
2490
- questions: list[dict[str, object]] = []
2491
- for index, item in enumerate(value):
2492
- if not isinstance(item, dict):
2510
+ questions: list[AskUserQuestion] = []
2511
+ for index, raw_item in enumerate(value):
2512
+ item = _string_key_dict(raw_item)
2513
+ if item is None:
2493
2514
  return [], f"Question at index {index} must be an object."
2494
2515
 
2495
2516
  question = _trimmed_string(item.get("question"))
@@ -2500,9 +2521,10 @@ def _parse_ask_user_questions(value: object) -> tuple[list[dict[str, object]], s
2500
2521
  if not isinstance(raw_options, list) or not raw_options:
2501
2522
  return [], f'Question at index {index} must include a non-empty "options" array.'
2502
2523
 
2503
- options: list[dict[str, str]] = []
2504
- for option_index, option in enumerate(raw_options):
2505
- if not isinstance(option, dict):
2524
+ options: list[AskUserOption] = []
2525
+ for option_index, raw_option in enumerate(raw_options):
2526
+ option = _string_key_dict(raw_option)
2527
+ if option is None:
2506
2528
  return [], f"Option {option_index} for question {index} must be an object."
2507
2529
 
2508
2530
  label = _trimmed_string(option.get("label"))
@@ -2512,13 +2534,13 @@ def _parse_ask_user_questions(value: object) -> tuple[list[dict[str, object]], s
2512
2534
  f'Option {option_index} for question {index} is missing a non-empty "label" string.',
2513
2535
  )
2514
2536
 
2515
- parsed_option = {"label": label}
2537
+ parsed_option: AskUserOption = {"label": label}
2516
2538
  description = _trimmed_string(option.get("description"))
2517
2539
  if description:
2518
2540
  parsed_option["description"] = description
2519
2541
  options.append(parsed_option)
2520
2542
 
2521
- parsed_question: dict[str, object] = {
2543
+ parsed_question: AskUserQuestion = {
2522
2544
  "question": question,
2523
2545
  "options": options,
2524
2546
  }
@@ -2530,15 +2552,13 @@ def _parse_ask_user_questions(value: object) -> tuple[list[dict[str, object]], s
2530
2552
  return questions, None
2531
2553
 
2532
2554
 
2533
- def _build_question_summary(questions: list[dict[str, object]]) -> str:
2555
+ def _build_question_summary(questions: list[AskUserQuestion]) -> str:
2534
2556
  lines = ["Waiting for user input."]
2535
2557
  for index, item in enumerate(questions):
2536
2558
  lines.append("")
2537
2559
  lines.append(f"{index + 1}. {item['question']}")
2538
2560
  lines.append(f" Mode: {'multi-select' if item.get('multiSelect') else 'single-select'}")
2539
2561
  for option in item["options"]:
2540
- if not isinstance(option, dict):
2541
- continue
2542
2562
  lines.append(f" - {option['label']}")
2543
2563
  if option.get("description"):
2544
2564
  lines.append(f" {option['description']}")
@@ -2,6 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass, field
4
4
  from pathlib import Path
5
+ from typing import Literal
6
+
7
+
8
+ SnapshotStatus = Literal["missing", "full", "partial", "deleted", "stale"]
5
9
 
6
10
 
7
11
  @dataclass
@@ -58,6 +62,18 @@ class FileState:
58
62
  return False, "File changed since it was read; read it again before editing."
59
63
  return True, None
60
64
 
65
+ def snapshot_status(self, path: Path) -> SnapshotStatus:
66
+ resolved = path.resolve()
67
+ snapshot = self._snapshots.get(resolved)
68
+ if snapshot is None:
69
+ return "missing"
70
+ if not resolved.exists():
71
+ return "deleted"
72
+ stat = resolved.stat()
73
+ if stat.st_mtime_ns != snapshot.mtime_ns or stat.st_size != snapshot.size:
74
+ return "stale"
75
+ return "full" if snapshot.full_read else "partial"
76
+
61
77
  def mark_written(self, path: Path) -> None:
62
78
  if path.exists():
63
79
  self.mark_read(path)
@@ -17,7 +17,7 @@ class ToolResult:
17
17
  followUpMessages: list[dict[str, Any]] | None = None
18
18
 
19
19
  def to_dict(self) -> dict[str, Any]:
20
- payload = {
20
+ payload: dict[str, Any] = {
21
21
  "ok": self.ok,
22
22
  "name": self.name,
23
23
  "output": self.output,
@@ -0,0 +1,2 @@
1
+ """Shared Deepy type definitions."""
2
+