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,202 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import signal
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from deepy.config import Settings, load_settings
10
+ from deepy.llm.provider import ProviderBundle
11
+ from deepy.llm.runner import RunSummary, run_prompt_once
12
+ from deepy.utils import json as json_utils
13
+
14
+ from .jsonl import DeepyJsonlSession, list_session_entries, project_sessions_dir
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class InterruptSummary:
19
+ session_id: str
20
+ killed_pids: list[int]
21
+ failed_pids: list[int]
22
+
23
+
24
+ @dataclass
25
+ class DeepySessionManager:
26
+ project_root: Path
27
+ settings: Settings | None = None
28
+ provider: ProviderBundle | None = None
29
+ deepy_home: Path | None = None
30
+ active_session_id: str | None = None
31
+ _interrupted_sessions: set[str] = field(default_factory=set, init=False, repr=False)
32
+
33
+ async def handle_user_prompt(
34
+ self,
35
+ prompt: str,
36
+ *,
37
+ session_id: str | None = None,
38
+ **run_kwargs: Any,
39
+ ) -> RunSummary:
40
+ target_session = session_id or self.active_session_id
41
+ if target_session:
42
+ return await self.reply_session(target_session, prompt, **run_kwargs)
43
+ return await self.create_session(prompt, **run_kwargs)
44
+
45
+ async def create_session(self, prompt: str, **run_kwargs: Any) -> RunSummary:
46
+ summary = await self._run(prompt, session_id=None, **run_kwargs)
47
+ self.active_session_id = summary.session_id
48
+ return summary
49
+
50
+ async def reply_session(
51
+ self,
52
+ session_id: str,
53
+ prompt: str,
54
+ **run_kwargs: Any,
55
+ ) -> RunSummary:
56
+ self.activate_session(session_id)
57
+ return await self._run(prompt, session_id=session_id, **run_kwargs)
58
+
59
+ def activate_session(self, session_id: str) -> None:
60
+ if not session_id:
61
+ raise ValueError("session_id is required.")
62
+ self.active_session_id = session_id
63
+
64
+ async def append_sdk_items(self, session_id: str, items: list[dict[str, Any]]) -> None:
65
+ session = DeepyJsonlSession.open(
66
+ self.project_root,
67
+ session_id,
68
+ deepy_home=self.deepy_home,
69
+ )
70
+ await session.add_items(items)
71
+
72
+ async def compact_session(self, session_id: str) -> None:
73
+ session = DeepyJsonlSession.open(
74
+ self.project_root,
75
+ session_id,
76
+ deepy_home=self.deepy_home,
77
+ )
78
+ items = await session.get_items()
79
+ if not items:
80
+ return
81
+ await session.clear_session()
82
+ await session.add_items(
83
+ [
84
+ {
85
+ "role": "system",
86
+ "content": (
87
+ "Earlier conversation history was compacted by Deepy. "
88
+ f"Compacted item count: {len(items)}."
89
+ ),
90
+ }
91
+ ]
92
+ )
93
+
94
+ def interrupt_active_session(self) -> InterruptSummary | None:
95
+ if not self.active_session_id:
96
+ return None
97
+ return self.interrupt_session(self.active_session_id)
98
+
99
+ def interrupt_session(self, session_id: str) -> InterruptSummary:
100
+ self._interrupted_sessions.add(session_id)
101
+ processes = _session_processes(self.project_root, session_id, self.deepy_home)
102
+ killed_pids, failed_pids = _kill_processes(processes)
103
+ _clear_session_processes(self.project_root, session_id, self.deepy_home)
104
+ return InterruptSummary(
105
+ session_id=session_id,
106
+ killed_pids=killed_pids,
107
+ failed_pids=failed_pids,
108
+ )
109
+
110
+ async def _run(
111
+ self,
112
+ prompt: str,
113
+ *,
114
+ session_id: str | None,
115
+ **run_kwargs: Any,
116
+ ) -> RunSummary:
117
+ effective_session_id = session_id
118
+
119
+ def should_interrupt() -> bool:
120
+ return bool(effective_session_id and effective_session_id in self._interrupted_sessions)
121
+
122
+ summary = await run_prompt_once(
123
+ prompt,
124
+ project_root=self.project_root,
125
+ settings=self.settings or load_settings(),
126
+ provider=self.provider,
127
+ session_id=effective_session_id,
128
+ should_interrupt=should_interrupt if effective_session_id else None,
129
+ **run_kwargs,
130
+ )
131
+ if effective_session_id is None:
132
+ effective_session_id = summary.session_id
133
+ if summary.interrupted:
134
+ self._interrupted_sessions.discard(effective_session_id)
135
+ self.active_session_id = summary.session_id
136
+ return summary
137
+
138
+
139
+ def _session_processes(
140
+ project_root: Path,
141
+ session_id: str,
142
+ deepy_home: Path | None,
143
+ ) -> dict[str, dict[str, str]]:
144
+ for entry in list_session_entries(project_root, deepy_home=deepy_home):
145
+ if entry.id == session_id and isinstance(entry.processes, dict):
146
+ return entry.processes
147
+ return {}
148
+
149
+
150
+ def _kill_processes(processes: dict[str, dict[str, str]]) -> tuple[list[int], list[int]]:
151
+ killed_pids: list[int] = []
152
+ failed_pids: list[int] = []
153
+ for raw_pid in processes:
154
+ try:
155
+ pid = int(raw_pid)
156
+ except ValueError:
157
+ continue
158
+ if _kill_process_group(pid) or _kill_process(pid):
159
+ killed_pids.append(pid)
160
+ else:
161
+ failed_pids.append(pid)
162
+ return killed_pids, failed_pids
163
+
164
+
165
+ def _kill_process_group(pid: int) -> bool:
166
+ try:
167
+ os.killpg(pid, signal.SIGKILL)
168
+ except OSError:
169
+ return False
170
+ return True
171
+
172
+
173
+ def _kill_process(pid: int) -> bool:
174
+ try:
175
+ os.kill(pid, signal.SIGKILL)
176
+ except OSError:
177
+ return False
178
+ return True
179
+
180
+
181
+ def _clear_session_processes(
182
+ project_root: Path,
183
+ session_id: str,
184
+ deepy_home: Path | None,
185
+ ) -> None:
186
+ index_path = project_sessions_dir(project_root, deepy_home) / "sessions-index.json"
187
+ if not index_path.is_file():
188
+ return
189
+ try:
190
+ raw = json_utils.loads(index_path.read_text(encoding="utf-8") or "{}")
191
+ except Exception:
192
+ return
193
+ changed = False
194
+ entries = raw.get("sessions")
195
+ if not isinstance(entries, list):
196
+ return
197
+ for entry in entries:
198
+ if isinstance(entry, dict) and entry.get("id") == session_id:
199
+ entry["processes"] = None
200
+ changed = True
201
+ if changed:
202
+ index_path.write_text(json_utils.dumps_pretty(raw) + "\n", encoding="utf-8")
deepy/skills.py ADDED
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Iterable
6
+
7
+ SKILL_MATCH_STOPWORDS = {
8
+ "a",
9
+ "an",
10
+ "and",
11
+ "for",
12
+ "from",
13
+ "in",
14
+ "of",
15
+ "on",
16
+ "or",
17
+ "the",
18
+ "to",
19
+ "use",
20
+ "with",
21
+ }
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class SkillInfo:
26
+ name: str
27
+ path: Path
28
+ description: str = ""
29
+ scope: str = "user"
30
+ is_loaded: bool = False
31
+
32
+
33
+ def discover_skills(project_root: Path, *, home: Path | None = None) -> list[SkillInfo]:
34
+ home_dir = home or Path.home()
35
+ roots = [
36
+ ("user", home_dir / ".agents" / "skills"),
37
+ ("project", project_root / ".deepy" / "skills"),
38
+ ]
39
+ by_name: dict[str, SkillInfo] = {}
40
+ for scope, root in roots:
41
+ for skill in _discover_skills_root(root, scope=scope):
42
+ by_name[_normalize_name(skill.name)] = skill
43
+ return sorted(by_name.values(), key=lambda item: item.name)
44
+
45
+
46
+ def find_skill(project_root: Path, name: str, *, home: Path | None = None) -> SkillInfo | None:
47
+ normalized = _normalize_name(name)
48
+ for skill in discover_skills(project_root, home=home):
49
+ if _normalize_name(skill.name) == normalized:
50
+ return skill
51
+ return None
52
+
53
+
54
+ def match_skills_for_prompt(
55
+ skills: Iterable[SkillInfo],
56
+ prompt: str,
57
+ *,
58
+ max_matches: int = 3,
59
+ ) -> list[SkillInfo]:
60
+ normalized_prompt = _normalize_match_text(prompt)
61
+ if not normalized_prompt:
62
+ return []
63
+ scored: list[tuple[int, str, SkillInfo]] = []
64
+ for skill in skills:
65
+ score = _skill_match_score(skill, normalized_prompt)
66
+ if score > 0:
67
+ scored.append((score, skill.name, skill))
68
+ scored.sort(key=lambda item: (-item[0], item[1]))
69
+ return [skill for _score, _name, skill in scored[:max_matches]]
70
+
71
+
72
+ def read_skill_body(skill: SkillInfo) -> str:
73
+ text = skill.path.read_text(encoding="utf-8", errors="replace")
74
+ _frontmatter, body = _split_frontmatter(text)
75
+ return body.strip()
76
+
77
+
78
+ def format_skills_for_prompt(skills: Iterable[SkillInfo]) -> str:
79
+ return _format_skills(skills, include_paths=True)
80
+
81
+
82
+ def format_skills_for_terminal(skills: Iterable[SkillInfo]) -> str:
83
+ return _format_skills(skills, include_paths=False)
84
+
85
+
86
+ def format_loaded_skills_for_prompt(skills: Iterable[SkillInfo]) -> str:
87
+ blocks: list[str] = []
88
+ for skill in sorted(skills, key=lambda item: item.name):
89
+ body = read_skill_body(skill)
90
+ if body:
91
+ blocks.append(f"<skill name=\"{skill.name}\">\n{body}\n</skill>")
92
+ return "\n\n".join(blocks) if blocks else "No skills loaded."
93
+
94
+
95
+ def _format_skills(skills: Iterable[SkillInfo], *, include_paths: bool) -> str:
96
+ grouped: dict[str, list[SkillInfo]] = {"project": [], "user": []}
97
+ for skill in skills:
98
+ grouped.setdefault(skill.scope, []).append(skill)
99
+
100
+ lines: list[str] = []
101
+ for scope in ("project", "user"):
102
+ items = grouped.get(scope) or []
103
+ if not items:
104
+ continue
105
+ lines.append(f"{scope.title()} skills:")
106
+ for skill in sorted(items, key=lambda item: item.name):
107
+ description = f" - {skill.description}" if skill.description else ""
108
+ path = f" ({skill.path})" if include_paths else ""
109
+ lines.append(f"- {skill.name}{description}{path}")
110
+ return "\n".join(lines) if lines else "No skills found."
111
+
112
+
113
+ def _discover_skills_root(root: Path, *, scope: str) -> list[SkillInfo]:
114
+ if not root.is_dir():
115
+ return []
116
+ skills: list[SkillInfo] = []
117
+ for entry in sorted(root.iterdir(), key=lambda item: item.name):
118
+ skill_path = entry / "SKILL.md"
119
+ if not entry.is_dir() or not skill_path.is_file():
120
+ continue
121
+ skill = read_skill_info(skill_path, default_name=entry.name, scope=scope)
122
+ if skill is not None:
123
+ skills.append(skill)
124
+ return skills
125
+
126
+
127
+ def read_skill_info(
128
+ path: Path,
129
+ *,
130
+ default_name: str | None = None,
131
+ scope: str = "user",
132
+ ) -> SkillInfo | None:
133
+ try:
134
+ text = path.read_text(encoding="utf-8", errors="replace")
135
+ except OSError:
136
+ return None
137
+ frontmatter, body = _split_frontmatter(text)
138
+ name = _clean_scalar(frontmatter.get("name")) or default_name or path.parent.name
139
+ description = _clean_scalar(frontmatter.get("description")) or _first_body_line(body)
140
+ return SkillInfo(name=name, description=description, path=path, scope=scope)
141
+
142
+
143
+ def _split_frontmatter(text: str) -> tuple[dict[str, str], str]:
144
+ lines = text.splitlines()
145
+ if not lines or lines[0].strip() != "---":
146
+ return {}, text
147
+ for index, line in enumerate(lines[1:], start=1):
148
+ if line.strip() == "---":
149
+ return _parse_simple_yaml(lines[1:index]), "\n".join(lines[index + 1 :])
150
+ return {}, text
151
+
152
+
153
+ def _parse_simple_yaml(lines: list[str]) -> dict[str, str]:
154
+ data: dict[str, str] = {}
155
+ for line in lines:
156
+ if ":" not in line:
157
+ continue
158
+ key, value = line.split(":", 1)
159
+ key = key.strip()
160
+ if key:
161
+ data[key] = value.strip().strip('"').strip("'")
162
+ return data
163
+
164
+
165
+ def _first_body_line(body: str) -> str:
166
+ for line in body.splitlines():
167
+ stripped = line.strip()
168
+ if stripped:
169
+ return stripped.lstrip("#").strip()
170
+ return ""
171
+
172
+
173
+ def _clean_scalar(value: str | None) -> str:
174
+ return value.strip() if isinstance(value, str) and value.strip() else ""
175
+
176
+
177
+ def _normalize_name(name: str) -> str:
178
+ return name.strip().lower()
179
+
180
+
181
+ def _skill_match_score(skill: SkillInfo, normalized_prompt: str) -> int:
182
+ name = _normalize_match_text(skill.name)
183
+ if name and name in normalized_prompt:
184
+ return 100 + len(name)
185
+ score = 0
186
+ for token in _match_tokens(f"{skill.name} {skill.description}"):
187
+ if f" {token} " in f" {normalized_prompt} ":
188
+ score += 1
189
+ return score
190
+
191
+
192
+ def _match_tokens(value: str) -> set[str]:
193
+ return {
194
+ token
195
+ for token in _normalize_match_text(value).split()
196
+ if len(token) >= 3 and token not in SKILL_MATCH_STOPWORDS
197
+ }
198
+
199
+
200
+ def _normalize_match_text(value: str) -> str:
201
+ chars = [char.lower() if char.isalnum() else " " for char in value]
202
+ return " ".join("".join(chars).split())
deepy/status.py ADDED
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from deepy.config import Settings
8
+ from deepy.prompts.runtime_context import build_runtime_context
9
+ from deepy.sessions import list_session_entries
10
+ from deepy.skills import discover_skills
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class StatusReport:
15
+ project_root: Path
16
+ model: str
17
+ api_key_configured: bool
18
+ context_window_tokens: int
19
+ compact_threshold_tokens: int
20
+ session_count: int
21
+ skill_count: int
22
+ runtime_context: str
23
+
24
+
25
+ def build_status_report(project_root: Path, settings: Settings) -> StatusReport:
26
+ root = project_root.resolve()
27
+ return StatusReport(
28
+ project_root=root,
29
+ model=settings.model.name,
30
+ api_key_configured=bool(settings.model.api_key),
31
+ context_window_tokens=settings.context.window_tokens,
32
+ compact_threshold_tokens=settings.context.resolved_compact_threshold,
33
+ session_count=len(list_session_entries(root)),
34
+ skill_count=len(discover_skills(root)),
35
+ runtime_context=build_runtime_context(root),
36
+ )
37
+
38
+
39
+ def format_status_report(report: StatusReport) -> str:
40
+ return "\n".join(
41
+ [
42
+ f"Project: {report.project_root}",
43
+ f"Model: {report.model}",
44
+ f"API key: {'configured' if report.api_key_configured else 'missing'}",
45
+ f"Context: {report.context_window_tokens} tokens",
46
+ f"Compact threshold: {report.compact_threshold_tokens} tokens",
47
+ f"Sessions: {report.session_count}",
48
+ f"Skills: {report.skill_count}",
49
+ "",
50
+ report.runtime_context,
51
+ ]
52
+ )
53
+
54
+
55
+ def status_report_to_dict(report: StatusReport) -> dict[str, Any]:
56
+ return {
57
+ "project_root": str(report.project_root),
58
+ "model": report.model,
59
+ "api_key_configured": report.api_key_configured,
60
+ "context_window_tokens": report.context_window_tokens,
61
+ "compact_threshold_tokens": report.compact_threshold_tokens,
62
+ "session_count": report.session_count,
63
+ "skill_count": report.skill_count,
64
+ "runtime_context": report.runtime_context,
65
+ }
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .builtin import ToolRuntime
4
+ from .result import ToolResult
5
+
6
+ __all__ = ["ToolResult", "ToolRuntime"]