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,98 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ IGNORED_TOP_LEVEL_ENTRIES = {
11
+ ".git",
12
+ ".mypy_cache",
13
+ ".pytest_cache",
14
+ ".ruff_cache",
15
+ ".venv",
16
+ "__pycache__",
17
+ "build",
18
+ "dist",
19
+ "wheels",
20
+ }
21
+
22
+
23
+ def build_runtime_context(project_root: Path, *, include_git_dirty: bool = True) -> str:
24
+ lines = [f"Project root: {project_root}"]
25
+ lines.append(f"Current working directory: {Path.cwd()}")
26
+ lines.append(f"Home directory: {Path.home()}")
27
+ lines.append(f"System: {platform.platform()}")
28
+ lines.append(f"Shell: {_shell_info()}")
29
+ lines.append(f"Python: {_python_info()}")
30
+ node = _command_output(project_root, ["node", "--version"])
31
+ lines.append(f"Node: {node or 'missing'}")
32
+ lines.append("Tool availability:")
33
+ lines.extend(f"- {tool}: {_tool_info(tool, project_root)}" for tool in ("rg", "jq", "ast-grep"))
34
+ branch = _git_output(project_root, ["git", "branch", "--show-current"])
35
+ if branch:
36
+ lines.append(f"Git branch: {branch}")
37
+ if include_git_dirty:
38
+ status = _git_output(project_root, ["git", "status", "--short"])
39
+ lines.append(f"Git dirty: {'yes' if status else 'no'}")
40
+ top_level = _top_level_entries(project_root)
41
+ if top_level:
42
+ lines.append("Top-level entries:")
43
+ lines.extend(f"- {entry}" for entry in top_level)
44
+ return "\n".join(lines)
45
+
46
+
47
+ def _shell_info() -> str:
48
+ shell = os.environ.get("SHELL") or os.environ.get("COMSPEC") or ""
49
+ return shell or "unknown"
50
+
51
+
52
+ def _python_info() -> str:
53
+ version = ".".join(str(part) for part in sys.version_info[:3])
54
+ return f"{sys.executable} ({version})"
55
+
56
+
57
+ def _tool_info(tool: str, cwd: Path) -> str:
58
+ path = shutil.which(tool)
59
+ if path is None:
60
+ return "missing"
61
+ version = _command_output(cwd, [path, "--version"])
62
+ first_line = version.splitlines()[0] if version else ""
63
+ return f"{path}" + (f" ({first_line})" if first_line else "")
64
+
65
+
66
+ def _command_output(project_root: Path, args: list[str]) -> str:
67
+ try:
68
+ completed = subprocess.run(
69
+ args,
70
+ cwd=project_root,
71
+ text=True,
72
+ capture_output=True,
73
+ timeout=2,
74
+ )
75
+ except (OSError, subprocess.TimeoutExpired):
76
+ return ""
77
+ if completed.returncode != 0:
78
+ return ""
79
+ return completed.stdout.strip()
80
+
81
+
82
+ def _git_output(project_root: Path, args: list[str]) -> str:
83
+ return _command_output(project_root, args)
84
+
85
+
86
+ def _top_level_entries(project_root: Path, limit: int = 30) -> list[str]:
87
+ try:
88
+ entries = sorted(project_root.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower()))
89
+ except OSError:
90
+ return []
91
+ names: list[str] = []
92
+ for entry in entries:
93
+ if entry.name in IGNORED_TOP_LEVEL_ENTRIES:
94
+ continue
95
+ names.append(f"{entry.name}/" if entry.is_dir() else entry.name)
96
+ if len(names) >= limit:
97
+ break
98
+ return names
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from deepy.config import Settings
6
+ from deepy.skills import (
7
+ SkillInfo,
8
+ discover_skills,
9
+ format_loaded_skills_for_prompt,
10
+ format_skills_for_prompt,
11
+ )
12
+
13
+ from .rules import AGENT_DRIFT_GUARD, load_project_rules
14
+ from .runtime_context import build_runtime_context
15
+ from .tool_docs import load_tool_docs
16
+
17
+
18
+ def build_system_prompt(
19
+ project_root: Path,
20
+ settings: Settings,
21
+ *,
22
+ project_rules: str | None = None,
23
+ skills: list[SkillInfo] | None = None,
24
+ loaded_skills: list[SkillInfo] | None = None,
25
+ runtime_context: str | None = None,
26
+ ) -> str:
27
+ resolved_project_rules = (
28
+ load_project_rules(project_root) if project_rules is None else project_rules.strip()
29
+ )
30
+ resolved_skills = discover_skills(project_root) if skills is None else skills
31
+ project_rules_block = resolved_project_rules or "No project rules found."
32
+ skills_block = format_skills_for_prompt(resolved_skills)
33
+ loaded_skills_block = format_loaded_skills_for_prompt(loaded_skills or [])
34
+ runtime_context_block = runtime_context or build_runtime_context(
35
+ project_root,
36
+ include_git_dirty=False,
37
+ )
38
+ tool_docs_block = load_tool_docs()
39
+ # Keep stable instructions before project/runtime blocks so DeepSeek can reuse
40
+ # a longer request prefix through its context cache.
41
+ return f"""You are Deepy, a terminal coding agent in the user's project.
42
+
43
+ Core rules:
44
+ - Work in the repo with tools: inspect, modify, test, verify.
45
+ - Preserve user changes. Prefer small, verifiable edits.
46
+ - Read before changing existing files.
47
+ - Use `modify` for file changes: `content` only creates new files; existing files use `old_string`/`new_string`.
48
+ - After project generators create scaffold files, read and edit the generated block instead of replacing the file.
49
+ - Ask only when blocked by missing intent or required approval.
50
+
51
+ Tool protocol:
52
+ Tool results are JSON strings: ok, name, output, error, metadata, awaitUserResponse.
53
+
54
+ Tool documentation:
55
+ {tool_docs_block}
56
+
57
+ Default skill:
58
+ {AGENT_DRIFT_GUARD}
59
+
60
+ Project rules:
61
+ {project_rules_block}
62
+
63
+ Available skills:
64
+ {skills_block}
65
+
66
+ Loaded skills:
67
+ {loaded_skills_block}
68
+
69
+ Runtime context:
70
+ Runtime: root={project_root}; model={settings.model.name}; thinking={settings.model.thinking_enabled}; reasoning={settings.model.reasoning_effort}
71
+ {runtime_context_block}
72
+ """
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import resources
4
+
5
+
6
+ TOOL_DOC_FILES = (
7
+ "bash.md",
8
+ "read.md",
9
+ "modify.md",
10
+ "AskUserQuestion.md",
11
+ "WebSearch.md",
12
+ "WebFetch.md",
13
+ )
14
+
15
+
16
+ def load_tool_docs() -> str:
17
+ docs = resources.files("deepy.data.tools")
18
+ sections: list[str] = []
19
+ for filename in TOOL_DOC_FILES:
20
+ sections.append(docs.joinpath(filename).read_text(encoding="utf-8").strip())
21
+ return "\n\n".join(sections)
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from .jsonl import (
4
+ DeepyJsonlSession,
5
+ SessionEntry,
6
+ list_session_entries,
7
+ project_code,
8
+ project_sessions_dir,
9
+ )
10
+
11
+ __all__ = [
12
+ "DeepyJsonlSession",
13
+ "SessionEntry",
14
+ "list_session_entries",
15
+ "project_code",
16
+ "project_sessions_dir",
17
+ ]
@@ -0,0 +1,306 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import uuid
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from deepy.llm.context import estimate_tokens_for_item
10
+ from deepy.llm.replay import sanitize_sdk_items_for_replay
11
+ from deepy.usage import TokenUsage, merge_usage, normalize_usage
12
+ from deepy.utils import json as json_utils
13
+
14
+ SESSION_INDEX_VERSION = 2
15
+ MAX_SESSION_INDEX_ENTRIES = 50
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class SessionEntry:
20
+ id: str
21
+ path: str
22
+ active_tokens: int
23
+ created_at: int
24
+ updated_at: int
25
+ processes: dict[str, dict[str, str]] | None = None
26
+ usage: dict[str, Any] | None = None
27
+
28
+
29
+ def project_code(project_root: Path) -> str:
30
+ text = str(project_root.resolve())
31
+ return text.replace("/", "-").replace("\\", "-").replace(":", "")
32
+
33
+
34
+ def project_sessions_dir(project_root: Path, deepy_home: Path | None = None) -> Path:
35
+ home = deepy_home or Path.home() / ".deepy"
36
+ return home / "projects" / project_code(project_root)
37
+
38
+
39
+ def _now_ms() -> int:
40
+ return int(time.time() * 1000)
41
+
42
+
43
+ def _role_from_item(item: dict[str, Any]) -> str:
44
+ role = item.get("role")
45
+ if isinstance(role, str) and role:
46
+ return role
47
+ item_type = item.get("type")
48
+ return item_type if isinstance(item_type, str) and item_type else "unknown"
49
+
50
+
51
+ def _content_from_item(item: dict[str, Any]) -> Any:
52
+ if "content" in item:
53
+ return item["content"]
54
+ if "output" in item:
55
+ return item["output"]
56
+ return ""
57
+
58
+
59
+ @dataclass
60
+ class DeepyJsonlSession:
61
+ session_id: str
62
+ path: Path
63
+ session_settings: object | None = None
64
+ _loaded_items: list[dict[str, Any]] | None = field(default=None, init=False, repr=False)
65
+
66
+ @classmethod
67
+ def create(
68
+ cls,
69
+ project_root: Path,
70
+ *,
71
+ deepy_home: Path | None = None,
72
+ session_id: str | None = None,
73
+ ) -> "DeepyJsonlSession":
74
+ sid = session_id or uuid.uuid4().hex
75
+ sessions_dir = project_sessions_dir(project_root, deepy_home)
76
+ return cls(session_id=sid, path=sessions_dir / f"{sid}.jsonl")
77
+
78
+ @classmethod
79
+ def open(
80
+ cls,
81
+ project_root: Path,
82
+ session_id: str,
83
+ *,
84
+ deepy_home: Path | None = None,
85
+ ) -> "DeepyJsonlSession":
86
+ sessions_dir = project_sessions_dir(project_root, deepy_home)
87
+ return cls(session_id=session_id, path=sessions_dir / f"{session_id}.jsonl")
88
+
89
+ async def get_items(self, limit: int | None = None) -> list[dict[str, Any]]:
90
+ items = self._load_items()
91
+ if limit is not None:
92
+ if limit <= 0:
93
+ return []
94
+ return items[-limit:]
95
+ return list(items)
96
+
97
+ async def add_items(self, items: list[dict[str, Any]]) -> None:
98
+ if not items:
99
+ return
100
+ self.path.parent.mkdir(parents=True, exist_ok=True)
101
+ with self.path.open("a", encoding="utf-8") as fh:
102
+ for item in items:
103
+ record = self._record_from_sdk_item(item)
104
+ fh.write(json_utils.dumps(record) + "\n")
105
+ if self._loaded_items is not None:
106
+ self._loaded_items = sanitize_sdk_items_for_replay(
107
+ [*self._loaded_items, *(dict(item) for item in items)]
108
+ )
109
+ self._touch_index(active_tokens=self._estimate_active_tokens())
110
+
111
+ async def pop_item(self) -> dict[str, Any] | None:
112
+ records = self._load_records()
113
+ if not records:
114
+ return None
115
+ popped = records.pop()
116
+ self.path.parent.mkdir(parents=True, exist_ok=True)
117
+ with self.path.open("w", encoding="utf-8") as fh:
118
+ for record in records:
119
+ fh.write(json_utils.dumps(record) + "\n")
120
+ self._loaded_items = [item for item in (_sdk_item_from_record(record) for record in records) if item]
121
+ self._touch_index(active_tokens=self._estimate_active_tokens(records))
122
+ return _sdk_item_from_record(popped)
123
+
124
+ async def clear_session(self) -> None:
125
+ self.path.parent.mkdir(parents=True, exist_ok=True)
126
+ self.path.write_text("", encoding="utf-8")
127
+ self._loaded_items = []
128
+ self._touch_index(active_tokens=0)
129
+
130
+ def record_usage(self, usage: TokenUsage | dict[str, Any] | None) -> None:
131
+ normalized = usage if isinstance(usage, TokenUsage) else normalize_usage(usage)
132
+ if not normalized.known:
133
+ return
134
+ previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
135
+ accumulated = merge_usage(previous.get("usage") if previous else None, normalized)
136
+ self._touch_index(usage=accumulated.to_dict())
137
+
138
+ def _record_from_sdk_item(self, item: dict[str, Any]) -> dict[str, Any]:
139
+ now = _now_ms()
140
+ return {
141
+ "id": uuid.uuid4().hex,
142
+ "session_id": self.session_id,
143
+ "role": _role_from_item(item),
144
+ "content": _content_from_item(item),
145
+ "created_at": now,
146
+ "meta": {"sdk_item": item},
147
+ }
148
+
149
+ def _load_records(self) -> list[dict[str, Any]]:
150
+ if not self.path.exists():
151
+ return []
152
+ records: list[dict[str, Any]] = []
153
+ with self.path.open("r", encoding="utf-8") as fh:
154
+ for line in fh:
155
+ line = line.strip()
156
+ if not line:
157
+ continue
158
+ try:
159
+ parsed = json_utils.loads(line)
160
+ except Exception:
161
+ continue
162
+ if isinstance(parsed, dict):
163
+ records.append(parsed)
164
+ return records
165
+
166
+ def _load_items(self) -> list[dict[str, Any]]:
167
+ if self._loaded_items is None:
168
+ loaded_items = [
169
+ item
170
+ for item in (_sdk_item_from_record(record) for record in self._load_records())
171
+ if item is not None
172
+ ]
173
+ self._loaded_items = sanitize_sdk_items_for_replay(loaded_items)
174
+ return list(self._loaded_items)
175
+
176
+ def _estimate_active_tokens(self, records: list[dict[str, Any]] | None = None) -> int:
177
+ source = records if records is not None else self._load_records()
178
+ return sum(_estimate_record_tokens(record) for record in source)
179
+
180
+ def _touch_index(
181
+ self,
182
+ *,
183
+ active_tokens: int | None = None,
184
+ usage: dict[str, Any] | None = None,
185
+ ) -> None:
186
+ self.path.parent.mkdir(parents=True, exist_ok=True)
187
+ index_path = self.path.parent / "sessions-index.json"
188
+ now = _now_ms()
189
+ raw = _read_index(index_path)
190
+ sessions = _index_sessions(raw)
191
+ previous = next((entry for entry in sessions if entry.get("id") == self.session_id), {})
192
+ sessions = [entry for entry in sessions if entry.get("id") != self.session_id]
193
+ sessions.insert(
194
+ 0,
195
+ {
196
+ "id": self.session_id,
197
+ "path": self.path.name,
198
+ "activeTokens": active_tokens
199
+ if active_tokens is not None
200
+ else _coerce_int(previous.get("activeTokens"), 0),
201
+ "createdAt": _coerce_int(previous.get("createdAt"), now),
202
+ "updatedAt": now,
203
+ **({"usage": usage} if usage is not None else {}),
204
+ **(
205
+ {"usage": previous["usage"]}
206
+ if usage is None and isinstance(previous.get("usage"), dict)
207
+ else {}
208
+ ),
209
+ **({"processes": previous["processes"]} if "processes" in previous else {}),
210
+ },
211
+ )
212
+ payload = {
213
+ "version": SESSION_INDEX_VERSION,
214
+ "sessions": sessions[:MAX_SESSION_INDEX_ENTRIES],
215
+ }
216
+ index_path.write_text(json_utils.dumps_pretty(payload) + "\n", encoding="utf-8")
217
+
218
+
219
+ def list_session_entries(project_root: Path, deepy_home: Path | None = None) -> list[SessionEntry]:
220
+ index_path = project_sessions_dir(project_root, deepy_home) / "sessions-index.json"
221
+ if not index_path.is_file():
222
+ return []
223
+ sessions = _index_sessions(_read_index(index_path))
224
+ entries: list[SessionEntry] = []
225
+ for item in sessions:
226
+ session_id = item.get("id")
227
+ path = item.get("path")
228
+ if not isinstance(session_id, str) or not session_id:
229
+ continue
230
+ if not isinstance(path, str) or not path:
231
+ path = f"{session_id}.jsonl"
232
+ usage = item.get("usage")
233
+ entries.append(
234
+ SessionEntry(
235
+ id=session_id,
236
+ path=path,
237
+ active_tokens=_coerce_int(item.get("activeTokens"), 0),
238
+ created_at=_coerce_int(item.get("createdAt"), 0),
239
+ updated_at=_coerce_int(item.get("updatedAt"), 0),
240
+ processes=_normalize_processes(item.get("processes")),
241
+ usage=usage if isinstance(usage, dict) else None,
242
+ )
243
+ )
244
+ return entries
245
+
246
+
247
+ def _read_index(index_path: Path) -> dict[str, Any]:
248
+ if not index_path.exists():
249
+ return {"version": SESSION_INDEX_VERSION, "sessions": []}
250
+ try:
251
+ raw = json_utils.loads(index_path.read_text(encoding="utf-8") or "{}")
252
+ except Exception:
253
+ return {"version": SESSION_INDEX_VERSION, "sessions": []}
254
+ return raw if isinstance(raw, dict) else {"version": SESSION_INDEX_VERSION, "sessions": []}
255
+
256
+
257
+ def _index_sessions(raw: dict[str, Any]) -> list[dict[str, Any]]:
258
+ sessions = raw.get("sessions")
259
+ if not isinstance(sessions, list):
260
+ return []
261
+ return [item for item in sessions if isinstance(item, dict)]
262
+
263
+
264
+ def _entry_for_session(index_path: Path, session_id: str) -> dict[str, Any] | None:
265
+ return next(
266
+ (entry for entry in _index_sessions(_read_index(index_path)) if entry.get("id") == session_id),
267
+ None,
268
+ )
269
+
270
+
271
+ def _sdk_item_from_record(record: dict[str, Any]) -> dict[str, Any] | None:
272
+ meta = record.get("meta")
273
+ if not isinstance(meta, dict):
274
+ return None
275
+ item = meta.get("sdk_item")
276
+ return dict(item) if isinstance(item, dict) else None
277
+
278
+
279
+ def _coerce_int(value: Any, default: int) -> int:
280
+ if isinstance(value, bool):
281
+ return default
282
+ return value if isinstance(value, int) else default
283
+
284
+
285
+ def _normalize_processes(value: Any) -> dict[str, dict[str, str]] | None:
286
+ if not isinstance(value, dict):
287
+ return None
288
+ processes: dict[str, dict[str, str]] = {}
289
+ for pid, entry in value.items():
290
+ if not isinstance(pid, str) or not pid:
291
+ continue
292
+ if isinstance(entry, dict):
293
+ start_time = entry.get("startTime")
294
+ command = entry.get("command")
295
+ processes[pid] = {
296
+ "startTime": start_time if isinstance(start_time, str) else "",
297
+ "command": command if isinstance(command, str) else "Running process...",
298
+ }
299
+ return processes or None
300
+
301
+
302
+ def _estimate_record_tokens(record: dict[str, Any]) -> int:
303
+ item = _sdk_item_from_record(record)
304
+ if item is not None:
305
+ return estimate_tokens_for_item(item)
306
+ return estimate_tokens_for_item(record.get("content", ""))