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,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class FileSnapshot:
|
|
9
|
+
mtime_ns: int
|
|
10
|
+
size: int
|
|
11
|
+
full_read: bool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class FileSnippet:
|
|
16
|
+
id: str
|
|
17
|
+
path: Path
|
|
18
|
+
start_line: int
|
|
19
|
+
end_line: int
|
|
20
|
+
text: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class FileState:
|
|
25
|
+
_snapshots: dict[Path, FileSnapshot] = field(default_factory=dict)
|
|
26
|
+
_snippets: dict[str, FileSnippet] = field(default_factory=dict)
|
|
27
|
+
_next_snippet_id: int = 0
|
|
28
|
+
|
|
29
|
+
def mark_read(self, path: Path, *, full: bool = True) -> None:
|
|
30
|
+
resolved = path.resolve()
|
|
31
|
+
stat = resolved.stat()
|
|
32
|
+
existing = self._snapshots.get(resolved)
|
|
33
|
+
self._snapshots[resolved] = FileSnapshot(
|
|
34
|
+
mtime_ns=stat.st_mtime_ns,
|
|
35
|
+
size=stat.st_size,
|
|
36
|
+
full_read=full or bool(existing and existing.full_read),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def check_writable(
|
|
40
|
+
self,
|
|
41
|
+
path: Path,
|
|
42
|
+
*,
|
|
43
|
+
require_read: bool = True,
|
|
44
|
+
allow_partial: bool = False,
|
|
45
|
+
) -> tuple[bool, str | None]:
|
|
46
|
+
resolved = path.resolve()
|
|
47
|
+
snapshot = self._snapshots.get(resolved)
|
|
48
|
+
if snapshot is None:
|
|
49
|
+
if require_read and resolved.exists():
|
|
50
|
+
return False, "File must be read before it can be modified."
|
|
51
|
+
return True, None
|
|
52
|
+
if require_read and not snapshot.full_read and not allow_partial:
|
|
53
|
+
return False, "File must be read before it can be modified."
|
|
54
|
+
if not resolved.exists():
|
|
55
|
+
return False, "File changed since it was read: it no longer exists."
|
|
56
|
+
stat = resolved.stat()
|
|
57
|
+
if stat.st_mtime_ns != snapshot.mtime_ns or stat.st_size != snapshot.size:
|
|
58
|
+
return False, "File changed since it was read; read it again before editing."
|
|
59
|
+
return True, None
|
|
60
|
+
|
|
61
|
+
def mark_written(self, path: Path) -> None:
|
|
62
|
+
if path.exists():
|
|
63
|
+
self.mark_read(path)
|
|
64
|
+
|
|
65
|
+
def create_snippet(
|
|
66
|
+
self,
|
|
67
|
+
path: Path,
|
|
68
|
+
*,
|
|
69
|
+
start_line: int,
|
|
70
|
+
end_line: int,
|
|
71
|
+
text: str,
|
|
72
|
+
) -> FileSnippet:
|
|
73
|
+
self._next_snippet_id += 1
|
|
74
|
+
snippet = FileSnippet(
|
|
75
|
+
id=f"snippet_{self._next_snippet_id}",
|
|
76
|
+
path=path.resolve(),
|
|
77
|
+
start_line=start_line,
|
|
78
|
+
end_line=end_line,
|
|
79
|
+
text=text,
|
|
80
|
+
)
|
|
81
|
+
self._snippets[snippet.id] = snippet
|
|
82
|
+
return snippet
|
|
83
|
+
|
|
84
|
+
def get_snippet(self, snippet_id: str) -> FileSnippet | None:
|
|
85
|
+
return self._snippets.get(snippet_id)
|
deepy/tools/result.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from deepy.utils import json as json_utils
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ToolResult:
|
|
11
|
+
ok: bool
|
|
12
|
+
name: str
|
|
13
|
+
output: str = ""
|
|
14
|
+
error: str | None = None
|
|
15
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
16
|
+
awaitUserResponse: bool = False
|
|
17
|
+
followUpMessages: list[dict[str, Any]] | None = None
|
|
18
|
+
|
|
19
|
+
def to_dict(self) -> dict[str, Any]:
|
|
20
|
+
payload = {
|
|
21
|
+
"ok": self.ok,
|
|
22
|
+
"name": self.name,
|
|
23
|
+
"output": self.output,
|
|
24
|
+
"error": self.error,
|
|
25
|
+
"metadata": self.metadata,
|
|
26
|
+
"awaitUserResponse": self.awaitUserResponse,
|
|
27
|
+
}
|
|
28
|
+
if self.followUpMessages is not None:
|
|
29
|
+
payload["followUpMessages"] = self.followUpMessages
|
|
30
|
+
return payload
|
|
31
|
+
|
|
32
|
+
def to_json(self) -> str:
|
|
33
|
+
return json_utils.dumps(self.to_dict())
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def ok_result(
|
|
37
|
+
cls,
|
|
38
|
+
name: str,
|
|
39
|
+
output: str = "",
|
|
40
|
+
*,
|
|
41
|
+
metadata: dict[str, Any] | None = None,
|
|
42
|
+
) -> "ToolResult":
|
|
43
|
+
return cls(ok=True, name=name, output=output, metadata=metadata or {})
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def error_result(
|
|
47
|
+
cls,
|
|
48
|
+
name: str,
|
|
49
|
+
error: str,
|
|
50
|
+
*,
|
|
51
|
+
output: str = "",
|
|
52
|
+
metadata: dict[str, Any] | None = None,
|
|
53
|
+
) -> "ToolResult":
|
|
54
|
+
return cls(ok=False, name=name, output=output, error=error, metadata=metadata or {})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
ShellKind = str
|
|
7
|
+
NUL_REDIRECT_RE = re.compile(r"(\d?&?>+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_shell_kind(shell_path: str) -> ShellKind:
|
|
11
|
+
executable = shell_path.replace("\\", "/").split("/")[-1].lower()
|
|
12
|
+
if executable in {"bash", "bash.exe"}:
|
|
13
|
+
return "bash"
|
|
14
|
+
if executable in {"zsh", "zsh.exe"}:
|
|
15
|
+
return "zsh"
|
|
16
|
+
return "unknown"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_shell_init_command(shell_path: str) -> str | None:
|
|
20
|
+
kind = get_shell_kind(shell_path)
|
|
21
|
+
if kind == "zsh":
|
|
22
|
+
return 'ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"; if [ -f "$ZSHRC" ]; then . "$ZSHRC"; fi'
|
|
23
|
+
if kind == "bash":
|
|
24
|
+
return 'BASHRC="${BASH_ENV:-$HOME/.bashrc}"; if [ -f "$BASHRC" ]; then . "$BASHRC"; fi'
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_disable_extglob_command(shell_path: str) -> str | None:
|
|
29
|
+
kind = get_shell_kind(shell_path)
|
|
30
|
+
if kind == "bash":
|
|
31
|
+
return "shopt -u extglob 2>/dev/null || true"
|
|
32
|
+
if kind == "zsh":
|
|
33
|
+
return "setopt NO_EXTENDED_GLOB 2>/dev/null || true"
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def rewrite_windows_null_redirect(command: str) -> str:
|
|
38
|
+
return NUL_REDIRECT_RE.sub(r"\1/dev/null", command)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def windows_path_to_posix_path(windows_path: str) -> str:
|
|
42
|
+
if windows_path.startswith("\\\\"):
|
|
43
|
+
return windows_path.replace("\\", "/")
|
|
44
|
+
drive_match = re.match(r"^([A-Za-z]):[/\\]", windows_path)
|
|
45
|
+
if drive_match:
|
|
46
|
+
drive_letter = drive_match.group(1).lower()
|
|
47
|
+
return f"/{drive_letter}{windows_path[2:].replace('\\', '/')}"
|
|
48
|
+
return windows_path.replace("\\", "/")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def posix_path_to_windows_path(posix_path: str) -> str:
|
|
52
|
+
if posix_path.startswith("//"):
|
|
53
|
+
return posix_path.replace("/", "\\")
|
|
54
|
+
|
|
55
|
+
cygdrive_match = re.match(r"^/cygdrive/([A-Za-z])(/|$)", posix_path)
|
|
56
|
+
if cygdrive_match:
|
|
57
|
+
drive_letter = cygdrive_match.group(1).upper()
|
|
58
|
+
rest = posix_path[len(f"/cygdrive/{cygdrive_match.group(1)}") :]
|
|
59
|
+
return f"{drive_letter}:{(rest or '\\').replace('/', '\\')}"
|
|
60
|
+
|
|
61
|
+
drive_match = re.match(r"^/([A-Za-z])(/|$)", posix_path)
|
|
62
|
+
if drive_match:
|
|
63
|
+
drive_letter = drive_match.group(1).upper()
|
|
64
|
+
rest = posix_path[2:]
|
|
65
|
+
return f"{drive_letter}:{(rest or '\\').replace('/', '\\')}"
|
|
66
|
+
|
|
67
|
+
return posix_path.replace("/", "\\")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def normalize_file_path(path: str, platform: str) -> str:
|
|
71
|
+
if platform == "win32":
|
|
72
|
+
return posix_path_to_windows_path(path)
|
|
73
|
+
return path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_absolute_file_path(path: str, platform: str) -> bool:
|
|
77
|
+
if platform == "win32":
|
|
78
|
+
if re.match(r"^[A-Za-z]:[/\\]", path):
|
|
79
|
+
return True
|
|
80
|
+
if path.startswith("\\\\"):
|
|
81
|
+
return True
|
|
82
|
+
return bool(re.match(r"^/(?:cygdrive/)?[A-Za-z](?:/|$)", path))
|
|
83
|
+
return path.startswith("/")
|
deepy/ui/__init__.py
ADDED
deepy/ui/app.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field, replace
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from deepy.llm.context import estimate_tokens_for_text
|
|
7
|
+
from deepy.sessions import SessionEntry
|
|
8
|
+
from deepy.skills import SkillInfo
|
|
9
|
+
from deepy.ui.ask_user_question import AskUserQuestionItem
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
AppView = Literal["chat", "sessions", "skills", "status", "question"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class StreamProgress:
|
|
17
|
+
text_tokens: int = 0
|
|
18
|
+
reasoning_tokens: int = 0
|
|
19
|
+
tool_calls: int = 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class DeepyAppState:
|
|
24
|
+
current_view: AppView = "chat"
|
|
25
|
+
busy: bool = False
|
|
26
|
+
messages: list[dict[str, Any]] = field(default_factory=list)
|
|
27
|
+
sessions: list[SessionEntry] = field(default_factory=list)
|
|
28
|
+
skills: list[SkillInfo] = field(default_factory=list)
|
|
29
|
+
status_line: str = ""
|
|
30
|
+
error_line: str = ""
|
|
31
|
+
stream_progress: StreamProgress = field(default_factory=StreamProgress)
|
|
32
|
+
running_processes: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
33
|
+
pending_questions: list[AskUserQuestionItem] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def set_view(state: DeepyAppState, view: AppView) -> DeepyAppState:
|
|
37
|
+
return replace(state, current_view=view)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def set_busy(state: DeepyAppState, busy: bool, *, status_line: str = "") -> DeepyAppState:
|
|
41
|
+
return replace(state, busy=busy, status_line=status_line)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def append_message(state: DeepyAppState, message: dict[str, Any]) -> DeepyAppState:
|
|
45
|
+
return replace(state, messages=[*state.messages, message])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def set_sessions(state: DeepyAppState, sessions: list[SessionEntry]) -> DeepyAppState:
|
|
49
|
+
return replace(state, sessions=list(sessions))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def set_skills(state: DeepyAppState, skills: list[SkillInfo]) -> DeepyAppState:
|
|
53
|
+
return replace(state, skills=list(skills))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def set_status_line(state: DeepyAppState, status_line: str) -> DeepyAppState:
|
|
57
|
+
return replace(state, status_line=status_line, error_line="")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def set_error_line(state: DeepyAppState, error_line: str) -> DeepyAppState:
|
|
61
|
+
return replace(state, error_line=error_line)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def update_stream_progress(
|
|
65
|
+
state: DeepyAppState,
|
|
66
|
+
*,
|
|
67
|
+
text_delta: str = "",
|
|
68
|
+
reasoning_delta: str = "",
|
|
69
|
+
tool_call: bool = False,
|
|
70
|
+
) -> DeepyAppState:
|
|
71
|
+
progress = state.stream_progress
|
|
72
|
+
return replace(
|
|
73
|
+
state,
|
|
74
|
+
stream_progress=StreamProgress(
|
|
75
|
+
text_tokens=progress.text_tokens + _estimate_tokens(text_delta),
|
|
76
|
+
reasoning_tokens=progress.reasoning_tokens + _estimate_tokens(reasoning_delta),
|
|
77
|
+
tool_calls=progress.tool_calls + (1 if tool_call else 0),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def set_running_processes(
|
|
83
|
+
state: DeepyAppState,
|
|
84
|
+
running_processes: dict[str, dict[str, str]],
|
|
85
|
+
) -> DeepyAppState:
|
|
86
|
+
return replace(
|
|
87
|
+
state,
|
|
88
|
+
running_processes={pid: dict(value) for pid, value in running_processes.items()},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def set_pending_questions(
|
|
93
|
+
state: DeepyAppState,
|
|
94
|
+
pending_questions: list[AskUserQuestionItem],
|
|
95
|
+
) -> DeepyAppState:
|
|
96
|
+
return replace(
|
|
97
|
+
state,
|
|
98
|
+
current_view="question" if pending_questions else state.current_view,
|
|
99
|
+
pending_questions=list(pending_questions),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def clear_for_new_session(state: DeepyAppState) -> DeepyAppState:
|
|
104
|
+
return replace(
|
|
105
|
+
state,
|
|
106
|
+
current_view="chat",
|
|
107
|
+
busy=False,
|
|
108
|
+
messages=[],
|
|
109
|
+
status_line="Started a new session.",
|
|
110
|
+
error_line="",
|
|
111
|
+
stream_progress=StreamProgress(),
|
|
112
|
+
running_processes={},
|
|
113
|
+
pending_questions=[],
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _estimate_tokens(text: str) -> int:
|
|
118
|
+
return estimate_tokens_for_text(text)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
from deepy.utils import json as json_utils
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
OTHER_VALUE = "__other__"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class AskUserQuestionOption:
|
|
14
|
+
label: str
|
|
15
|
+
description: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class AskUserQuestionItem:
|
|
20
|
+
question: str
|
|
21
|
+
options: list[AskUserQuestionOption]
|
|
22
|
+
multi_select: bool | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class PendingAskUserQuestion:
|
|
27
|
+
message_id: str
|
|
28
|
+
session_id: str
|
|
29
|
+
questions: list[AskUserQuestionItem]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class AskUserQuestionOptionEntry:
|
|
34
|
+
label: str
|
|
35
|
+
value: str
|
|
36
|
+
description: str | None = None
|
|
37
|
+
is_other: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_options(question: AskUserQuestionItem | None) -> list[AskUserQuestionOptionEntry]:
|
|
41
|
+
if question is None:
|
|
42
|
+
return []
|
|
43
|
+
return [
|
|
44
|
+
*[
|
|
45
|
+
AskUserQuestionOptionEntry(
|
|
46
|
+
label=option.label,
|
|
47
|
+
value=option.label,
|
|
48
|
+
description=option.description,
|
|
49
|
+
)
|
|
50
|
+
for option in question.options
|
|
51
|
+
],
|
|
52
|
+
AskUserQuestionOptionEntry(label="Other", value=OTHER_VALUE, is_other=True),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_answer_for_question(
|
|
57
|
+
question: AskUserQuestionItem,
|
|
58
|
+
focused_option: AskUserQuestionOptionEntry | None,
|
|
59
|
+
selected_values: list[str],
|
|
60
|
+
other_text: str,
|
|
61
|
+
) -> str | None:
|
|
62
|
+
trimmed_other = other_text.strip()
|
|
63
|
+
if question.multi_select:
|
|
64
|
+
labels = [value.strip() for value in selected_values if value != OTHER_VALUE and value.strip()]
|
|
65
|
+
if OTHER_VALUE in selected_values and not trimmed_other:
|
|
66
|
+
return None
|
|
67
|
+
if trimmed_other:
|
|
68
|
+
labels.append(trimmed_other)
|
|
69
|
+
return ", ".join(labels) if labels else None
|
|
70
|
+
|
|
71
|
+
if focused_option is None:
|
|
72
|
+
return None
|
|
73
|
+
if focused_option.is_other:
|
|
74
|
+
return trimmed_other or None
|
|
75
|
+
return focused_option.label
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def find_pending_ask_user_question(
|
|
79
|
+
messages: list[Mapping[str, Any]],
|
|
80
|
+
status: str | None,
|
|
81
|
+
) -> PendingAskUserQuestion | None:
|
|
82
|
+
if status != "waiting_for_user":
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
for message in reversed(messages):
|
|
86
|
+
if message.get("role") != "tool" or message.get("visible") is False:
|
|
87
|
+
continue
|
|
88
|
+
questions = parse_ask_user_question_content(message.get("content"))
|
|
89
|
+
if not questions:
|
|
90
|
+
continue
|
|
91
|
+
message_id = message.get("id")
|
|
92
|
+
session_id = message.get("session_id") or message.get("sessionId")
|
|
93
|
+
if not isinstance(message_id, str) or not isinstance(session_id, str):
|
|
94
|
+
continue
|
|
95
|
+
return PendingAskUserQuestion(
|
|
96
|
+
message_id=message_id,
|
|
97
|
+
session_id=session_id,
|
|
98
|
+
questions=questions,
|
|
99
|
+
)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_ask_user_question_content(content: Any) -> list[AskUserQuestionItem]:
|
|
104
|
+
if not isinstance(content, str) or not content:
|
|
105
|
+
return []
|
|
106
|
+
try:
|
|
107
|
+
parsed = json_utils.loads(content)
|
|
108
|
+
except json_utils.JSONDecodeError:
|
|
109
|
+
return []
|
|
110
|
+
if not isinstance(parsed, Mapping) or parsed.get("awaitUserResponse") is not True:
|
|
111
|
+
return []
|
|
112
|
+
metadata = parsed.get("metadata")
|
|
113
|
+
if not isinstance(metadata, Mapping) or metadata.get("kind") != "ask_user_question":
|
|
114
|
+
return []
|
|
115
|
+
return normalize_questions(metadata.get("questions"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def normalize_questions(raw: Any) -> list[AskUserQuestionItem]:
|
|
119
|
+
if not isinstance(raw, list):
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
questions: list[AskUserQuestionItem] = []
|
|
123
|
+
for item in raw:
|
|
124
|
+
if not isinstance(item, Mapping):
|
|
125
|
+
continue
|
|
126
|
+
question = _stripped_string(item.get("question"))
|
|
127
|
+
raw_options = item.get("options")
|
|
128
|
+
if not question or not isinstance(raw_options, list):
|
|
129
|
+
continue
|
|
130
|
+
options = [
|
|
131
|
+
option
|
|
132
|
+
for raw_option in raw_options
|
|
133
|
+
if (option := normalize_option(raw_option)) is not None
|
|
134
|
+
]
|
|
135
|
+
if not options:
|
|
136
|
+
continue
|
|
137
|
+
multi_select = item.get("multiSelect")
|
|
138
|
+
questions.append(
|
|
139
|
+
AskUserQuestionItem(
|
|
140
|
+
question=question,
|
|
141
|
+
options=options,
|
|
142
|
+
multi_select=multi_select if isinstance(multi_select, bool) else None,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
return questions
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def normalize_option(raw: Any) -> AskUserQuestionOption | None:
|
|
149
|
+
if not isinstance(raw, Mapping):
|
|
150
|
+
return None
|
|
151
|
+
label = _stripped_string(raw.get("label"))
|
|
152
|
+
if not label:
|
|
153
|
+
return None
|
|
154
|
+
description = _stripped_string(raw.get("description"))
|
|
155
|
+
return AskUserQuestionOption(label=label, description=description or None)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def format_ask_user_question_answers(answers: Mapping[str, str]) -> str:
|
|
159
|
+
answer_text = ", ".join(
|
|
160
|
+
f'"{_escape_answer_part(question)}"="{_escape_answer_part(answer)}"'
|
|
161
|
+
for question, answer in answers.items()
|
|
162
|
+
)
|
|
163
|
+
return (
|
|
164
|
+
f"User has answered your questions: {answer_text}. "
|
|
165
|
+
"You can now continue with the user's answers in mind."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def format_ask_user_question_decline() -> str:
|
|
170
|
+
return (
|
|
171
|
+
"The user declined to answer the questions. Continue with the available context, "
|
|
172
|
+
"or ask again if the information is required."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _stripped_string(value: Any) -> str:
|
|
177
|
+
return value.strip() if isinstance(value, str) else ""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _escape_answer_part(value: str) -> str:
|
|
181
|
+
normalized = " ".join(value.split())
|
|
182
|
+
return normalized.replace("\\", "\\\\").replace('"', '\\"')
|
deepy/ui/exit_summary.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
INNER_WIDTH = 98
|
|
8
|
+
CONTENT_WIDTH = INNER_WIDTH - 4
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class UsageFields:
|
|
13
|
+
prompt_tokens: int = 0
|
|
14
|
+
completion_tokens: int = 0
|
|
15
|
+
cached_tokens: int = 0
|
|
16
|
+
reasoning_tokens: int = 0
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def has_usage(self) -> bool:
|
|
20
|
+
return self.prompt_tokens > 0 or self.completion_tokens > 0 or self.reasoning_tokens > 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_usage_fields(usage: Any) -> UsageFields:
|
|
24
|
+
if not isinstance(usage, Mapping):
|
|
25
|
+
return UsageFields()
|
|
26
|
+
|
|
27
|
+
prompt_tokens = _number_field(usage.get("prompt_tokens"))
|
|
28
|
+
completion_tokens = _number_field(usage.get("completion_tokens"))
|
|
29
|
+
cached_tokens = 0
|
|
30
|
+
|
|
31
|
+
prompt_details = usage.get("prompt_tokens_details")
|
|
32
|
+
if isinstance(prompt_details, Mapping):
|
|
33
|
+
cached_tokens = _number_field(prompt_details.get("cached_tokens"))
|
|
34
|
+
|
|
35
|
+
if cached_tokens == 0:
|
|
36
|
+
cached_tokens = _number_field(usage.get("prompt_cache_hit_tokens"))
|
|
37
|
+
completion_details = usage.get("completion_tokens_details")
|
|
38
|
+
output_details = usage.get("output_tokens_details")
|
|
39
|
+
reasoning_tokens = 0
|
|
40
|
+
if isinstance(completion_details, Mapping):
|
|
41
|
+
reasoning_tokens = _number_field(completion_details.get("reasoning_tokens"))
|
|
42
|
+
if reasoning_tokens == 0 and isinstance(output_details, Mapping):
|
|
43
|
+
reasoning_tokens = _number_field(output_details.get("reasoning_tokens"))
|
|
44
|
+
if reasoning_tokens == 0:
|
|
45
|
+
reasoning_tokens = _number_field(usage.get("reasoning_tokens"))
|
|
46
|
+
|
|
47
|
+
return UsageFields(
|
|
48
|
+
prompt_tokens=prompt_tokens,
|
|
49
|
+
completion_tokens=completion_tokens,
|
|
50
|
+
cached_tokens=cached_tokens,
|
|
51
|
+
reasoning_tokens=reasoning_tokens,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_exit_summary_text(
|
|
56
|
+
*,
|
|
57
|
+
session: Any | None = None,
|
|
58
|
+
messages: list[Mapping[str, Any]] | None = None,
|
|
59
|
+
model: str | None = None,
|
|
60
|
+
) -> str:
|
|
61
|
+
usage = extract_usage_fields(_get_usage(session))
|
|
62
|
+
assistant_count = sum(1 for message in messages or [] if message.get("role") == "assistant")
|
|
63
|
+
|
|
64
|
+
rows = [
|
|
65
|
+
"",
|
|
66
|
+
"Goodbye!",
|
|
67
|
+
"",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
if usage.has_usage:
|
|
71
|
+
rows.extend(_usage_rows(usage, assistant_count=assistant_count, model=model or "unknown"))
|
|
72
|
+
|
|
73
|
+
rows.append("")
|
|
74
|
+
body = "\n".join(_box_line(row) for row in rows)
|
|
75
|
+
border = "─" * INNER_WIDTH
|
|
76
|
+
return f"╭{border}╮\n{body}\n╰{border}╯"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _usage_rows(usage: UsageFields, *, assistant_count: int, model: str) -> list[str]:
|
|
80
|
+
col_model = 26
|
|
81
|
+
col_reqs = 6
|
|
82
|
+
col_input = 14
|
|
83
|
+
col_output = 14
|
|
84
|
+
col_cached = 14
|
|
85
|
+
col_reasoning = 14
|
|
86
|
+
table_width = col_model + col_reqs + col_input + col_output + col_cached + col_reasoning
|
|
87
|
+
header = (
|
|
88
|
+
_pad_right("Cumulative Model Usage", col_model)
|
|
89
|
+
+ _pad_left("Reqs", col_reqs)
|
|
90
|
+
+ _pad_left("Input Tokens", col_input)
|
|
91
|
+
+ _pad_left("Output Tokens", col_output)
|
|
92
|
+
+ _pad_left("Cached Tokens", col_cached)
|
|
93
|
+
+ _pad_left("Reasoning", col_reasoning)
|
|
94
|
+
)
|
|
95
|
+
data = (
|
|
96
|
+
_pad_right(model, col_model)
|
|
97
|
+
+ _pad_right(str(assistant_count).rjust(col_reqs), col_reqs)
|
|
98
|
+
+ _pad_right(_format_number(usage.prompt_tokens).rjust(col_input), col_input)
|
|
99
|
+
+ _pad_right(_format_number(usage.completion_tokens).rjust(col_output), col_output)
|
|
100
|
+
+ _pad_right(_format_number(usage.cached_tokens).rjust(col_cached), col_cached)
|
|
101
|
+
+ _pad_right(_format_number(usage.reasoning_tokens).rjust(col_reasoning), col_reasoning)
|
|
102
|
+
)
|
|
103
|
+
return [
|
|
104
|
+
header,
|
|
105
|
+
"─" * table_width,
|
|
106
|
+
data,
|
|
107
|
+
"",
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _get_usage(session: Any | None) -> Any:
|
|
112
|
+
if session is None:
|
|
113
|
+
return None
|
|
114
|
+
if isinstance(session, Mapping):
|
|
115
|
+
return session.get("usage")
|
|
116
|
+
return getattr(session, "usage", None)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _number_field(value: Any) -> int:
|
|
120
|
+
if isinstance(value, bool):
|
|
121
|
+
return 0
|
|
122
|
+
if isinstance(value, int):
|
|
123
|
+
return value
|
|
124
|
+
if isinstance(value, float) and value.is_integer():
|
|
125
|
+
return int(value)
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _format_number(value: int) -> str:
|
|
130
|
+
return f"{value:,}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _box_line(text: str) -> str:
|
|
134
|
+
return f"│ {_pad_right(text, CONTENT_WIDTH)} │"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _pad_right(text: str, width: int) -> str:
|
|
138
|
+
return text + " " * max(0, width - len(text))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _pad_left(text: str, width: int) -> str:
|
|
142
|
+
return " " * max(0, width - len(text)) + text
|