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.
- deepy/__init__.py +9 -0
- deepy/__main__.py +7 -0
- deepy/cli.py +413 -0
- deepy/config/__init__.py +21 -0
- deepy/config/settings.py +237 -0
- deepy/data/__init__.py +1 -0
- deepy/data/tools/AskUserQuestion.md +10 -0
- deepy/data/tools/WebFetch.md +9 -0
- deepy/data/tools/WebSearch.md +9 -0
- deepy/data/tools/__init__.py +1 -0
- deepy/data/tools/bash.md +7 -0
- deepy/data/tools/edit.md +13 -0
- deepy/data/tools/modify.md +17 -0
- deepy/data/tools/read.md +8 -0
- deepy/data/tools/write.md +12 -0
- deepy/errors.py +63 -0
- deepy/llm/__init__.py +13 -0
- deepy/llm/agent.py +31 -0
- deepy/llm/context.py +109 -0
- deepy/llm/events.py +187 -0
- deepy/llm/model_capabilities.py +7 -0
- deepy/llm/provider.py +81 -0
- deepy/llm/replay.py +120 -0
- deepy/llm/runner.py +412 -0
- deepy/llm/thinking.py +30 -0
- deepy/prompts/__init__.py +6 -0
- deepy/prompts/compact.py +100 -0
- deepy/prompts/rules.py +24 -0
- deepy/prompts/runtime_context.py +98 -0
- deepy/prompts/system.py +72 -0
- deepy/prompts/tool_docs.py +21 -0
- deepy/sessions/__init__.py +17 -0
- deepy/sessions/jsonl.py +306 -0
- deepy/sessions/manager.py +202 -0
- deepy/skills.py +202 -0
- deepy/status.py +65 -0
- deepy/tools/__init__.py +6 -0
- deepy/tools/agents.py +343 -0
- deepy/tools/builtin.py +2113 -0
- deepy/tools/file_state.py +85 -0
- deepy/tools/result.py +54 -0
- deepy/tools/shell_utils.py +83 -0
- deepy/ui/__init__.py +5 -0
- deepy/ui/app.py +118 -0
- deepy/ui/ask_user_question.py +182 -0
- deepy/ui/exit_summary.py +142 -0
- deepy/ui/loading_text.py +87 -0
- deepy/ui/markdown.py +152 -0
- deepy/ui/message_view.py +546 -0
- deepy/ui/prompt_buffer.py +176 -0
- deepy/ui/prompt_input.py +286 -0
- deepy/ui/session_list.py +140 -0
- deepy/ui/session_picker.py +179 -0
- deepy/ui/slash_commands.py +67 -0
- deepy/ui/styles.py +21 -0
- deepy/ui/terminal.py +959 -0
- deepy/ui/thinking_state.py +29 -0
- deepy/ui/welcome.py +195 -0
- deepy/update_check.py +195 -0
- deepy/usage.py +192 -0
- deepy/utils/__init__.py +15 -0
- deepy/utils/debug_logger.py +62 -0
- deepy/utils/error_logger.py +107 -0
- deepy/utils/json.py +29 -0
- deepy/utils/notify.py +66 -0
- deepy_cli-0.1.1.dist-info/METADATA +205 -0
- deepy_cli-0.1.1.dist-info/RECORD +69 -0
- deepy_cli-0.1.1.dist-info/WHEEL +4 -0
- 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
|
+
}
|