evaporator 0.2.0__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.
- evaporator/__init__.py +1 -0
- evaporator/__main__.py +3 -0
- evaporator/app/__init__.py +3 -0
- evaporator/app/app.tcss +55 -0
- evaporator/app/chat.py +618 -0
- evaporator/app/chat_commands.py +263 -0
- evaporator/app/chat_confirm.py +233 -0
- evaporator/app/figures.py +81 -0
- evaporator/app/main.py +616 -0
- evaporator/app/onboarding.py +561 -0
- evaporator/app/theme.py +40 -0
- evaporator/app/widgets.py +334 -0
- evaporator/callsites.py +106 -0
- evaporator/capture.py +86 -0
- evaporator/chat/__init__.py +1 -0
- evaporator/chat/api_engine.py +202 -0
- evaporator/chat/claude_engine.py +187 -0
- evaporator/chat/codex_engine.py +169 -0
- evaporator/chat/discover.py +211 -0
- evaporator/chat/engine.py +47 -0
- evaporator/chat/gate.py +78 -0
- evaporator/cli.py +414 -0
- evaporator/compile.py +513 -0
- evaporator/connect.py +80 -0
- evaporator/control.py +384 -0
- evaporator/deid.py +174 -0
- evaporator/extraction.py +273 -0
- evaporator/graduate.py +255 -0
- evaporator/guard.py +399 -0
- evaporator/handoff.py +179 -0
- evaporator/ingest/__init__.py +106 -0
- evaporator/ingest/common.py +76 -0
- evaporator/ingest/langfuse.py +61 -0
- evaporator/ingest/mapping.py +42 -0
- evaporator/ingest/providers.py +81 -0
- evaporator/labels.py +45 -0
- evaporator/llm/__init__.py +60 -0
- evaporator/llm/anthropic_client.py +64 -0
- evaporator/llm/openai_client.py +43 -0
- evaporator/llm/subscriptions.py +313 -0
- evaporator/mcp.py +364 -0
- evaporator/ood.py +61 -0
- evaporator/pr.py +89 -0
- evaporator/pricing.py +61 -0
- evaporator/program.py +180 -0
- evaporator/runner.py +78 -0
- evaporator/runner_main.py +67 -0
- evaporator/scan.py +252 -0
- evaporator/settings.py +196 -0
- evaporator/skill/SKILL.md +46 -0
- evaporator/stats.py +23 -0
- evaporator/store.py +74 -0
- evaporator/synth.py +225 -0
- evaporator/trace.py +144 -0
- evaporator/tui.py +314 -0
- evaporator-0.2.0.dist-info/METADATA +422 -0
- evaporator-0.2.0.dist-info/RECORD +61 -0
- evaporator-0.2.0.dist-info/WHEEL +5 -0
- evaporator-0.2.0.dist-info/entry_points.txt +2 -0
- evaporator-0.2.0.dist-info/licenses/LICENSE +202 -0
- evaporator-0.2.0.dist-info/top_level.txt +1 -0
evaporator/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
evaporator/__main__.py
ADDED
evaporator/app/app.tcss
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/* Pitch black, like the fx UI. Colours are tui.py's palette; the "evaporator" theme (theme.py) blacks out the rest. */
|
|
2
|
+
Screen { background: #000000; color: #c3cfdf; }
|
|
3
|
+
* { scrollbar-background: #000000; scrollbar-background-hover: #000000; scrollbar-background-active: #000000; scrollbar-corner-color: #000000;
|
|
4
|
+
scrollbar-color: #3d4350; scrollbar-color-hover: #5b6474; scrollbar-color-active: #8ab4ff; }
|
|
5
|
+
Header { background: #000000; color: #8ab4ff; }
|
|
6
|
+
Footer { background: #000000; }
|
|
7
|
+
.panel { padding: 0 3; height: 1fr; }
|
|
8
|
+
.title { color: #8ab4ff; text-style: bold; margin-bottom: 1; }
|
|
9
|
+
.hidden { display: none; }
|
|
10
|
+
#note { color: #9aa8ba; margin-bottom: 1; }
|
|
11
|
+
#status { color: #e8743b; margin: 1 0; height: auto; }
|
|
12
|
+
OptionList { height: auto; max-height: 14; background: #000000; border: round #3d4350; }
|
|
13
|
+
OptionList:focus { border: round #8ab4ff; background-tint: #000000 0%; }
|
|
14
|
+
OptionList > .option-list--option-highlighted { background: #3559b0; color: white; }
|
|
15
|
+
OptionList > .option-list--option-disabled { color: #6b7587; }
|
|
16
|
+
Input { margin-bottom: 0; background: #000000; border: round #3d4350; }
|
|
17
|
+
Input:focus { border: round #8ab4ff; background-tint: #000000 0%; }
|
|
18
|
+
Button, Button.-primary { margin-right: 2; background: #000000; color: #c3cfdf; border: round #3d4350; }
|
|
19
|
+
Button.-primary { color: #8ab4ff; border: round #4c7be0; }
|
|
20
|
+
Button:hover, Button.-primary:hover { background: #000000; border: round #8ab4ff; }
|
|
21
|
+
Button:focus, Button.-primary:focus { background: #000000; color: #ffb36b; border: round #e8743b; text-style: bold; background-tint: #000000 0%; }
|
|
22
|
+
Button.-active { background: #000000; border: round #8ab4ff; tint: transparent; }
|
|
23
|
+
Checkbox { background: #000000; border: round #3d4350; }
|
|
24
|
+
Checkbox:focus { border: round #8ab4ff; background-tint: #000000 0%; }
|
|
25
|
+
TextArea { background: #000000; border: round #3d4350; }
|
|
26
|
+
TextArea:focus { border: round #8ab4ff; }
|
|
27
|
+
DataTable { background: #000000; }
|
|
28
|
+
#snippet { height: 8; margin: 1 0; }
|
|
29
|
+
#instructions { color: #4fc68b; margin-bottom: 1; height: auto; }
|
|
30
|
+
Horizontal { height: auto; }
|
|
31
|
+
#topbar { height: 1; background: #000000; color: #8ab4ff; padding: 0 1; }
|
|
32
|
+
#banner { height: auto; color: #e8743b; padding: 0 1; }
|
|
33
|
+
#panes { height: 1fr; }
|
|
34
|
+
#sites { width: 46; height: 1fr; background: #000000; border: round #3d4350; }
|
|
35
|
+
#sites:focus { border: round #8ab4ff; background-tint: #000000 0%; }
|
|
36
|
+
#detail-scroll { height: 1fr; padding: 0 1; }
|
|
37
|
+
#detail { height: auto; }
|
|
38
|
+
ModalScreen { background: #000000 70%; align: center middle; }
|
|
39
|
+
.dialog { width: 84; height: auto; max-height: 90%; padding: 1 2; background: #000000; border: round #4c7be0; }
|
|
40
|
+
.dialog.wide { width: 116; }
|
|
41
|
+
.dialog DataTable { height: auto; max-height: 20; }
|
|
42
|
+
.buttons { margin-top: 1; }
|
|
43
|
+
#chat-header { height: 1; background: #000000; color: #8ab4ff; padding: 0 1; }
|
|
44
|
+
#transcript { height: 1fr; padding: 0 1; background: #000000; }
|
|
45
|
+
#transcript > Static { height: auto; }
|
|
46
|
+
.chat-info { color: #c3cfdf; }
|
|
47
|
+
.chat-hint { color: #9aa8ba; }
|
|
48
|
+
.chat-user { color: #8ab4ff; text-style: bold; margin-top: 1; }
|
|
49
|
+
.chat-tool { color: #9aa8ba; }
|
|
50
|
+
.chat-error { color: #e8743b; }
|
|
51
|
+
.chat-reply { background: #000000; margin: 0; padding: 0; }
|
|
52
|
+
#chat-status { height: 1; color: #4fc68b; padding: 0 1; }
|
|
53
|
+
#prompt { border: round #3d4350; background: #000000; }
|
|
54
|
+
#prompt:focus { border: round #8ab4ff; }
|
|
55
|
+
#chat-hint { height: 1; color: #7f8a9c; padding: 0 1; background: #000000; }
|
evaporator/app/chat.py
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"""The chat: one line of what was found, a prompt at the bottom, answers above it (like Claude Code).
|
|
2
|
+
|
|
3
|
+
Setup is detection, not questions: the connection and the logs are found in a worker thread and said in one line.
|
|
4
|
+
Only a real choice (several logs) or a real gap (no connection) opens a screen. Engine turns run on a daemon thread
|
|
5
|
+
guarded by one busy flag, so a second Enter never starts a second engine process and quitting never waits for a turn;
|
|
6
|
+
the UI is only touched through call_from_thread. Quitting closes the engine and kills every agent child.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import replace
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Callable, Optional
|
|
18
|
+
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
from textual import work
|
|
21
|
+
from textual.app import App, ComposeResult
|
|
22
|
+
from textual.binding import Binding
|
|
23
|
+
from textual.containers import Vertical, VerticalScroll
|
|
24
|
+
from textual.screen import ModalScreen
|
|
25
|
+
from textual.timer import Timer
|
|
26
|
+
from textual.widget import Widget
|
|
27
|
+
from textual.widgets import Input, Label, Markdown, OptionList, Static
|
|
28
|
+
from textual.widgets.option_list import Option
|
|
29
|
+
|
|
30
|
+
from ..chat.api_engine import ApiEngine
|
|
31
|
+
from ..chat.claude_engine import ClaudeEngine
|
|
32
|
+
from ..chat.codex_engine import CodexEngine
|
|
33
|
+
from ..chat.discover import LogCandidate, find_logs, pick_connection, remember_logs
|
|
34
|
+
from ..chat.engine import Engine, Event
|
|
35
|
+
from ..chat.gate import Gate
|
|
36
|
+
from ..connect import list_models
|
|
37
|
+
from ..handoff import agent_launch, fx_has_foreign_entry, install_fx
|
|
38
|
+
from ..llm.subscriptions import detect, kill_all
|
|
39
|
+
from ..mcp import Server
|
|
40
|
+
from ..settings import SUBSCRIPTIONS, ProjectConfig, Resolved, UserConfig, load_project, project_root, save_project, save_user_config
|
|
41
|
+
from .chat_commands import TYPE_PATH, SlashCommands, command_word
|
|
42
|
+
from .chat_confirm import NOT_BAA_TAG, Card, ConfirmFlow, changed_reports, report_stamps
|
|
43
|
+
from .figures import ScanFigure, WelcomeFigure, figure_fits
|
|
44
|
+
from .main import default_exe, one_line
|
|
45
|
+
from .onboarding import SNIPPET, ConnectScreen, KeyScreen, ModelScreen, Picked, Selection
|
|
46
|
+
from .theme import use_black_theme
|
|
47
|
+
from .widgets import SPINNER
|
|
48
|
+
|
|
49
|
+
CONNECTION_WORDS = {
|
|
50
|
+
"claude-code": "your Claude Code sign-in",
|
|
51
|
+
"codex": "your Codex sign-in",
|
|
52
|
+
"anthropic": "your Anthropic API key",
|
|
53
|
+
"openai": "your OpenAI API key",
|
|
54
|
+
"gateway": "your gateway key",
|
|
55
|
+
}
|
|
56
|
+
SUGGESTION = "Try: which of my AI calls can become code?"
|
|
57
|
+
BUSY = "Still working on the last message"
|
|
58
|
+
NO_CONNECTION = ("No AI connection found. Sign in with `claude auth login` (Claude Code) or `codex login` (Codex) "
|
|
59
|
+
"in a terminal, or connect one here.")
|
|
60
|
+
NOT_CONNECTED = ("There is no AI connection for this chat yet. Type /model to connect one, or sign in with "
|
|
61
|
+
"`claude auth login` or `codex login` and start evaporator again.")
|
|
62
|
+
NOT_CONNECTED_SENSITIVE = ("This project has personal data, so the chat needs an API key on a BAA-covered endpoint. "
|
|
63
|
+
"Type /model to connect one.")
|
|
64
|
+
FOOTER = "Enter sends · /help for commands · ctrl+q quits"
|
|
65
|
+
CAPTURE = (
|
|
66
|
+
"To find out which AI calls can become code I need a log of them. Wrap the function that makes the call:\n\n"
|
|
67
|
+
f"```python\n{SNIPPET}```\n\n"
|
|
68
|
+
"Once some calls have gone through, start evaporator again, or tell me where your logs are."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def make_engine(resolved: Resolved, project: ProjectConfig, gate_path: Path, *, root: Optional[Path] = None,
|
|
73
|
+
evaporator_exe: Optional[str] = None) -> Engine:
|
|
74
|
+
"""The engine for this connection: the signed-in agent CLI, else the app's own API-key loop."""
|
|
75
|
+
root = Path(root) if root is not None else project_root()
|
|
76
|
+
if resolved.provider == "claude-code":
|
|
77
|
+
return ClaudeEngine(resolved, root, evaporator_exe or default_exe(), Path(gate_path))
|
|
78
|
+
if resolved.provider == "codex":
|
|
79
|
+
return CodexEngine(resolved, root, evaporator_exe or default_exe(), Path(gate_path))
|
|
80
|
+
return ApiEngine(resolved, Server(root / ".evaporator", resolved, project, gate=Gate(Path(gate_path))))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_DEFAULT_MAKE_ENGINE = make_engine
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Line(Static):
|
|
87
|
+
"""One plain transcript line. Text from the agent or the logs is never parsed as markup."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, text: str, classes: str = "") -> None:
|
|
90
|
+
super().__init__(Text(text), classes=classes)
|
|
91
|
+
self.plain = text
|
|
92
|
+
|
|
93
|
+
def set(self, text: str) -> None:
|
|
94
|
+
self.plain = text
|
|
95
|
+
self.update(Text(text))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Reply(Markdown):
|
|
99
|
+
def __init__(self, text: str) -> None:
|
|
100
|
+
super().__init__(text, classes="chat-reply")
|
|
101
|
+
self.plain = text
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class LogPickerScreen(ModalScreen):
|
|
105
|
+
"""Several call logs were found: pick one (Esc: none for now). With typed=True (/logs) the last option is
|
|
106
|
+
"Type a path…", which dismisses with TYPE_PATH."""
|
|
107
|
+
|
|
108
|
+
BINDINGS = [Binding("escape", "skip", "skip")]
|
|
109
|
+
|
|
110
|
+
def __init__(self, candidates: "list[LogCandidate]", typed: bool = False) -> None:
|
|
111
|
+
super().__init__()
|
|
112
|
+
self.candidates = list(candidates)
|
|
113
|
+
self.typed = typed
|
|
114
|
+
|
|
115
|
+
def compose(self) -> ComposeResult:
|
|
116
|
+
options = [Option(Text(c.describe()), id=f"log{i}") for i, c in enumerate(self.candidates)]
|
|
117
|
+
if self.typed:
|
|
118
|
+
options.append(Option("Type a path…", id=TYPE_PATH))
|
|
119
|
+
with Vertical(classes="dialog"):
|
|
120
|
+
yield Label("Which logs should I use?", classes="title")
|
|
121
|
+
yield OptionList(*options)
|
|
122
|
+
yield Static(Text("Enter picks · Esc skips for now", style="dim"))
|
|
123
|
+
|
|
124
|
+
def on_mount(self) -> None:
|
|
125
|
+
options = self.query_one(OptionList)
|
|
126
|
+
options.highlighted = 0
|
|
127
|
+
options.focus()
|
|
128
|
+
|
|
129
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
130
|
+
event.stop()
|
|
131
|
+
self.dismiss(TYPE_PATH if event.option_id == TYPE_PATH else self.candidates[event.option_index])
|
|
132
|
+
|
|
133
|
+
def action_skip(self) -> None:
|
|
134
|
+
self.dismiss(None)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ChatConnectScreen(ConnectScreen):
|
|
138
|
+
"""The connect screen, but Esc goes back to the chat (where the sign-in hint stays visible)."""
|
|
139
|
+
|
|
140
|
+
BINDINGS = [Binding("escape", "back", "back to the chat")]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ChatApp(SlashCommands, ConfirmFlow, App[Optional[str]]):
|
|
144
|
+
"""Returns None when the user quits, "dashboard" when they ask for the dashboard (/dashboard)."""
|
|
145
|
+
|
|
146
|
+
CSS_PATH = "app.tcss"
|
|
147
|
+
TITLE = "evaporator"
|
|
148
|
+
BINDINGS = [
|
|
149
|
+
Binding("ctrl+q", "quit", "quit", priority=True),
|
|
150
|
+
Binding("ctrl+c", "quit", "quit", priority=True, show=False),
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
root: Path,
|
|
156
|
+
*,
|
|
157
|
+
discover_logs: Callable[[Path], "list[LogCandidate]"] = find_logs,
|
|
158
|
+
pick: Callable[[], Optional[Resolved]] = pick_connection,
|
|
159
|
+
make_engine: Callable[[Resolved, ProjectConfig, Path], Engine] = make_engine,
|
|
160
|
+
evaporator_exe: Optional[str] = None,
|
|
161
|
+
) -> None:
|
|
162
|
+
super().__init__()
|
|
163
|
+
use_black_theme(self)
|
|
164
|
+
self.root = Path(root)
|
|
165
|
+
self.discover_logs, self.pick = discover_logs, pick
|
|
166
|
+
self.evaporator_exe = evaporator_exe or default_exe()
|
|
167
|
+
if make_engine is _DEFAULT_MAKE_ENGINE: # the real factory also needs where to run and which evaporator
|
|
168
|
+
self.make_engine = lambda r, p, g: _DEFAULT_MAKE_ENGINE(r, p, g, root=self.root, evaporator_exe=self.evaporator_exe)
|
|
169
|
+
else:
|
|
170
|
+
self.make_engine = make_engine
|
|
171
|
+
self.detect_fn: Callable = detect # used by the connect screens and /agent; tests replace them
|
|
172
|
+
self.list_models_fn: Callable = list_models
|
|
173
|
+
self.launch_fn: Callable = agent_launch
|
|
174
|
+
self.run_agent: Callable = subprocess.call
|
|
175
|
+
self.install_fn: Callable = install_fx
|
|
176
|
+
self.foreign_fn: Callable = fx_has_foreign_entry
|
|
177
|
+
self.fx_home: Optional[Path] = None # None: ~/.fx
|
|
178
|
+
self.fx_replace: Optional[bool] = None # the answer to "replace your fx entry?", asked once per session
|
|
179
|
+
self.greeted = False # the first greeting says what was found; later ones say what changed
|
|
180
|
+
self.resolved: Optional[Resolved] = None
|
|
181
|
+
self.project = ProjectConfig()
|
|
182
|
+
self.logs_desc: Optional[str] = None
|
|
183
|
+
self.logs_name: Optional[str] = None
|
|
184
|
+
self.engine: Optional[Engine] = None
|
|
185
|
+
self.gate_path: Optional[Path] = None # the current engine's gate file
|
|
186
|
+
self.gate_paths: "list[Path]" = [] # every gate file made this session, all deleted on exit
|
|
187
|
+
self.selection: Optional[Selection] = None
|
|
188
|
+
self.setting_up = True
|
|
189
|
+
self.busy = False
|
|
190
|
+
self.turn = 0 # which turn busy belongs to; an engine swapped mid-turn still ends its turn
|
|
191
|
+
self.status = ""
|
|
192
|
+
self.started = 0.0
|
|
193
|
+
self.timer: Optional[Timer] = None
|
|
194
|
+
self.welcome: Optional[WelcomeFigure] = None # animates until the first message
|
|
195
|
+
self.scanning: Optional[ScanFigure] = None # the one scan animation, while a scan tool call runs
|
|
196
|
+
self.quitting = False # set on exit: a turn thread still running posts nothing more
|
|
197
|
+
|
|
198
|
+
# -- layout --------------------------------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def compose(self) -> ComposeResult:
|
|
201
|
+
yield Static(id="chat-header")
|
|
202
|
+
yield VerticalScroll(id="transcript")
|
|
203
|
+
yield Static(id="chat-status")
|
|
204
|
+
yield Input(placeholder="Ask about your AI calls…", id="prompt")
|
|
205
|
+
yield Static(Text(FOOTER), id="chat-hint")
|
|
206
|
+
|
|
207
|
+
def on_mount(self) -> None:
|
|
208
|
+
self._part("#chat-status").display = False
|
|
209
|
+
self._header("looking for your logs and AI connection…")
|
|
210
|
+
if figure_fits(self):
|
|
211
|
+
self.welcome = self._add(WelcomeFigure())
|
|
212
|
+
self.prompt.focus()
|
|
213
|
+
self._setup()
|
|
214
|
+
|
|
215
|
+
def _part(self, selector: str, kind: type = Widget):
|
|
216
|
+
"""A widget of the chat itself, even while a dialog or the connect screens are on top."""
|
|
217
|
+
return self.screen_stack[0].query_one(selector, kind)
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def prompt(self) -> Input:
|
|
221
|
+
return self._part("#prompt", Input)
|
|
222
|
+
|
|
223
|
+
def _header(self, rest: str) -> None:
|
|
224
|
+
self._part("#chat-header", Static).update(Text(f"evaporator · {rest}", no_wrap=True, overflow="ellipsis"))
|
|
225
|
+
|
|
226
|
+
def header_text(self) -> str:
|
|
227
|
+
return str(self._part("#chat-header", Static).content)
|
|
228
|
+
|
|
229
|
+
def transcript_lines(self) -> "list[str]":
|
|
230
|
+
return [w.plain for w in self._part("#transcript").children if isinstance(w, (Line, Reply, Card))]
|
|
231
|
+
|
|
232
|
+
def status_text(self) -> str:
|
|
233
|
+
return self.status if self.busy else ""
|
|
234
|
+
|
|
235
|
+
def _add(self, widget: Widget) -> Widget:
|
|
236
|
+
transcript = self._part("#transcript", VerticalScroll)
|
|
237
|
+
transcript.mount(widget)
|
|
238
|
+
transcript.scroll_end(animate=False)
|
|
239
|
+
return widget
|
|
240
|
+
|
|
241
|
+
def _line(self, text: str, kind: str = "info") -> Line:
|
|
242
|
+
return self._add(Line(text, f"chat-{kind}"))
|
|
243
|
+
|
|
244
|
+
def _post(self, fn: Callable, *args) -> None:
|
|
245
|
+
if self.quitting:
|
|
246
|
+
return
|
|
247
|
+
try:
|
|
248
|
+
self.call_from_thread(fn, *args)
|
|
249
|
+
except RuntimeError:
|
|
250
|
+
pass # the app already closed
|
|
251
|
+
|
|
252
|
+
# -- setup: connection and logs, found rather than asked -----------------------------------------------------
|
|
253
|
+
|
|
254
|
+
@work(thread=True, exclusive=True, group="setup", exit_on_error=False)
|
|
255
|
+
def _setup(self) -> None:
|
|
256
|
+
problem = None
|
|
257
|
+
try:
|
|
258
|
+
resolved = self.pick()
|
|
259
|
+
except Exception as exc: # a broken config file: say so and offer the connect flow
|
|
260
|
+
resolved, problem = None, one_line(exc)
|
|
261
|
+
try:
|
|
262
|
+
project = load_project(self.root)
|
|
263
|
+
except (OSError, ValueError):
|
|
264
|
+
project = ProjectConfig()
|
|
265
|
+
try:
|
|
266
|
+
candidates = self.discover_logs(self.root)
|
|
267
|
+
except Exception:
|
|
268
|
+
candidates = []
|
|
269
|
+
saved = self._path(project.logs)
|
|
270
|
+
chosen: Optional[LogCandidate] = None
|
|
271
|
+
if saved is not None and saved.is_file(): # chosen before and still there
|
|
272
|
+
chosen = next((c for c in candidates if c.path.resolve() == saved.resolve()), None)
|
|
273
|
+
else:
|
|
274
|
+
saved = None
|
|
275
|
+
if project.logs: # the saved logs are gone: forget them rather than chat about a missing file
|
|
276
|
+
project = self._forget_logs(project)
|
|
277
|
+
if len(candidates) == 1:
|
|
278
|
+
chosen = candidates[0]
|
|
279
|
+
project = self._remember(chosen, project)
|
|
280
|
+
self._post(self._found, resolved, project, candidates, chosen, saved, problem)
|
|
281
|
+
|
|
282
|
+
def _path(self, value: Optional[str]) -> Optional[Path]:
|
|
283
|
+
if not value:
|
|
284
|
+
return None
|
|
285
|
+
path = Path(value).expanduser()
|
|
286
|
+
return path if path.is_absolute() else self.root / path
|
|
287
|
+
|
|
288
|
+
def _forget_logs(self, project: ProjectConfig) -> ProjectConfig:
|
|
289
|
+
project = replace(project, logs=None, labels=None)
|
|
290
|
+
try:
|
|
291
|
+
save_project(self.root, project)
|
|
292
|
+
except (OSError, ValueError):
|
|
293
|
+
pass # still forgotten for this session
|
|
294
|
+
return project
|
|
295
|
+
|
|
296
|
+
def _remember(self, cand: LogCandidate, project: ProjectConfig) -> ProjectConfig:
|
|
297
|
+
try:
|
|
298
|
+
return remember_logs(self.root, cand)
|
|
299
|
+
except (OSError, ValueError): # unsaved, but still used this session
|
|
300
|
+
return replace(project, logs=str(cand.path), labels=str(cand.labels) if cand.labels else None)
|
|
301
|
+
|
|
302
|
+
def _found(self, resolved: Optional[Resolved], project: ProjectConfig, candidates: "list[LogCandidate]",
|
|
303
|
+
chosen: Optional[LogCandidate], saved: Optional[Path], problem: Optional[str]) -> None:
|
|
304
|
+
self.resolved, self.project = resolved, project
|
|
305
|
+
if problem:
|
|
306
|
+
self._line(f"Could not read your saved connection: {problem}", "error")
|
|
307
|
+
if chosen is not None or saved is not None:
|
|
308
|
+
self._use_logs(chosen, saved)
|
|
309
|
+
self._connect()
|
|
310
|
+
elif not candidates:
|
|
311
|
+
self._connect()
|
|
312
|
+
else:
|
|
313
|
+
self._line(f"Found {len(candidates)} call logs here.")
|
|
314
|
+
self.push_screen(LogPickerScreen(candidates), self._picked)
|
|
315
|
+
|
|
316
|
+
def _use_logs(self, cand: Optional[LogCandidate], path: Optional[Path] = None) -> None:
|
|
317
|
+
if cand is not None:
|
|
318
|
+
self.logs_desc = cand.describe()
|
|
319
|
+
path = cand.path
|
|
320
|
+
else:
|
|
321
|
+
self.logs_desc = path.name
|
|
322
|
+
try:
|
|
323
|
+
self.logs_name = path.relative_to(self.root).as_posix()
|
|
324
|
+
except ValueError:
|
|
325
|
+
self.logs_name = str(path)
|
|
326
|
+
|
|
327
|
+
def _picked(self, cand: Optional[LogCandidate]) -> None:
|
|
328
|
+
if cand is None:
|
|
329
|
+
self._line("No logs picked for now; you can tell me where your logs are.")
|
|
330
|
+
else:
|
|
331
|
+
self.project = self._remember(cand, self.project)
|
|
332
|
+
self._use_logs(cand)
|
|
333
|
+
self._connect()
|
|
334
|
+
|
|
335
|
+
def _connect(self) -> None:
|
|
336
|
+
if self.resolved is not None:
|
|
337
|
+
return self._greet()
|
|
338
|
+
self._header("not connected")
|
|
339
|
+
self._line(NO_CONNECTION)
|
|
340
|
+
self._connect_flow()
|
|
341
|
+
|
|
342
|
+
# the Connect -> (Key) -> Model chain, as in onboarding; every step can go back but not out
|
|
343
|
+
def _connect_flow(self) -> None:
|
|
344
|
+
# a project with personal data is steered to an API key; a subscription is warned about once (decision 9)
|
|
345
|
+
self.push_screen(ChatConnectScreen(self.detect_fn, sensitive=bool(self.project.sensitive)), self._on_connect)
|
|
346
|
+
|
|
347
|
+
def _on_connect(self, selection: Optional[Selection]) -> None:
|
|
348
|
+
if selection is None: # Esc: back to the chat, still not connected
|
|
349
|
+
self.setting_up = False
|
|
350
|
+
self.prompt.focus()
|
|
351
|
+
return
|
|
352
|
+
self.selection = selection
|
|
353
|
+
if selection.provider in SUBSCRIPTIONS and self.project.sensitive: # Connect an API key: back to the choices
|
|
354
|
+
self.warn_subscription(lambda: self._choose_model([]), self._connect_flow, selection.provider)
|
|
355
|
+
elif selection.provider in SUBSCRIPTIONS:
|
|
356
|
+
self._choose_model([])
|
|
357
|
+
else:
|
|
358
|
+
self._ask_key()
|
|
359
|
+
|
|
360
|
+
def _ask_key(self) -> None:
|
|
361
|
+
sel = self.selection
|
|
362
|
+
self.push_screen(KeyScreen(sel.provider, sel.base_url, self.list_models_fn),
|
|
363
|
+
lambda models: self._connect_flow() if models is None else self._choose_model(models))
|
|
364
|
+
|
|
365
|
+
def _choose_model(self, models: "list[str]") -> None:
|
|
366
|
+
self.push_screen(ModelScreen(self.selection.provider, models), self._on_model)
|
|
367
|
+
|
|
368
|
+
def _on_model(self, pick: Optional[Picked]) -> None:
|
|
369
|
+
sel = self.selection
|
|
370
|
+
if pick is None:
|
|
371
|
+
return self._connect_flow() if sel.provider in SUBSCRIPTIONS else self._ask_key()
|
|
372
|
+
try:
|
|
373
|
+
save_user_config(UserConfig(provider=sel.provider, model=pick.model, base_url=sel.base_url))
|
|
374
|
+
except (OSError, ValueError) as exc:
|
|
375
|
+
self._line(f"Could not save the connection: {one_line(exc)}", "error")
|
|
376
|
+
return self._connect_flow()
|
|
377
|
+
self._repick()
|
|
378
|
+
|
|
379
|
+
@work(thread=True, exclusive=True, group="setup", exit_on_error=False)
|
|
380
|
+
def _repick(self) -> None:
|
|
381
|
+
try:
|
|
382
|
+
resolved = self.pick()
|
|
383
|
+
except Exception:
|
|
384
|
+
resolved = None
|
|
385
|
+
self._post(self._repicked, resolved)
|
|
386
|
+
|
|
387
|
+
def _repicked(self, resolved: Optional[Resolved]) -> None:
|
|
388
|
+
if resolved is None:
|
|
389
|
+
self._line("Still no working connection; pick one again.", "error")
|
|
390
|
+
return self._connect_flow()
|
|
391
|
+
if self.selection is not None and resolved.provider != self.selection.provider:
|
|
392
|
+
self._line(f"EVAPORATOR_PROVIDER overrides your choice: using {resolved.provider}.")
|
|
393
|
+
self.resolved = resolved
|
|
394
|
+
self._greet()
|
|
395
|
+
|
|
396
|
+
def _show_header(self) -> None:
|
|
397
|
+
header = Text(f"evaporator · {self.resolved.describe()}", no_wrap=True, overflow="ellipsis")
|
|
398
|
+
if self.personal_on_subscription() and self.project.allow_subscription:
|
|
399
|
+
header.append(" · ").append(NOT_BAA_TAG, style="yellow")
|
|
400
|
+
header.append(f" · {self.logs_name or 'no logs yet'}")
|
|
401
|
+
self._part("#chat-header", Static).update(header)
|
|
402
|
+
|
|
403
|
+
def _greet(self) -> None:
|
|
404
|
+
resolved = self.resolved
|
|
405
|
+
words = CONNECTION_WORDS.get(resolved.provider, f"your {resolved.provider} connection")
|
|
406
|
+
self._show_header()
|
|
407
|
+
if self.greeted: # a new connection (/model, or an API key after a refused subscription): a fresh conversation
|
|
408
|
+
self._line(f"Now using {words} ({resolved.describe()}). This is a new conversation.")
|
|
409
|
+
elif self.logs_desc is not None:
|
|
410
|
+
self._line(f"Found {self.logs_desc} · using {words}.")
|
|
411
|
+
self._line(SUGGESTION, "hint")
|
|
412
|
+
else:
|
|
413
|
+
self._line(f"No call logs found here yet · using {words}.")
|
|
414
|
+
self._add(Reply(CAPTURE))
|
|
415
|
+
self.greeted = True
|
|
416
|
+
if self.personal_on_subscription():
|
|
417
|
+
return self.warn_subscription(self._begin) # no subscription engine starts before the warning is answered
|
|
418
|
+
self._begin()
|
|
419
|
+
|
|
420
|
+
def _begin(self) -> None:
|
|
421
|
+
self._start_engine()
|
|
422
|
+
self.setting_up = False
|
|
423
|
+
self.prompt.focus()
|
|
424
|
+
|
|
425
|
+
def _start_engine(self) -> None:
|
|
426
|
+
"""A new engine for the current connection and project; the old one (if any) is closed first."""
|
|
427
|
+
self._stop_engine()
|
|
428
|
+
try:
|
|
429
|
+
# A fresh gate file per engine: a request a closed engine wrote (or writes later, from a thread that
|
|
430
|
+
# outlived close) can never be shown, let alone approved, under the new connection.
|
|
431
|
+
self._drop_gate(self.gate_path)
|
|
432
|
+
fd, name = tempfile.mkstemp(prefix="evaporator-gate-", suffix=".json")
|
|
433
|
+
os.close(fd)
|
|
434
|
+
self.gate_path = Path(name)
|
|
435
|
+
self.gate_paths.append(self.gate_path)
|
|
436
|
+
self.engine = self.make_engine(self.resolved, self.project, self.gate_path)
|
|
437
|
+
except Exception as exc:
|
|
438
|
+
self._line(f"Could not start the chat: {one_line(exc)}", "error")
|
|
439
|
+
|
|
440
|
+
def _stop_engine(self) -> None:
|
|
441
|
+
"""Close the engine for good (close is final; a new connection makes a new engine)."""
|
|
442
|
+
engine, self.engine = self.engine, None
|
|
443
|
+
if engine is not None:
|
|
444
|
+
try:
|
|
445
|
+
engine.close()
|
|
446
|
+
except Exception:
|
|
447
|
+
pass
|
|
448
|
+
|
|
449
|
+
# -- a turn ----------------------------------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
452
|
+
if event.input.id != "prompt" or event.input.screen is not self.screen:
|
|
453
|
+
return # the connect screens' own inputs
|
|
454
|
+
event.stop()
|
|
455
|
+
text = event.value.strip()
|
|
456
|
+
if not text:
|
|
457
|
+
return
|
|
458
|
+
word = command_word(text)
|
|
459
|
+
if word is not None:
|
|
460
|
+
if self.run_command(word, text):
|
|
461
|
+
event.input.value = ""
|
|
462
|
+
return
|
|
463
|
+
if self.busy:
|
|
464
|
+
self.notify(BUSY) # the typed text stays in the prompt
|
|
465
|
+
return
|
|
466
|
+
if self.setting_up:
|
|
467
|
+
self.notify("Still getting set up, one moment")
|
|
468
|
+
return
|
|
469
|
+
event.input.value = ""
|
|
470
|
+
self.send_message(text)
|
|
471
|
+
|
|
472
|
+
def send_message(self, text: str) -> None:
|
|
473
|
+
"""Echo the user's message (typed, or an answer to a confirmation) and run it as a turn."""
|
|
474
|
+
if self.engine is not None and self.resolved.provider == "codex" and self.project.sensitive is None:
|
|
475
|
+
# Codex can read the project's files even in its read-only sandbox (Claude only gets Evaporator's tools,
|
|
476
|
+
# whose listing shows no log text): the personal-data answer comes before its first turn
|
|
477
|
+
return self.ask_personal(lambda: self.send_message(text), lambda: self._unsent(text), "Before Codex reads this project")
|
|
478
|
+
if self.welcome is not None: # the welcome picture stays in the scrollback, still
|
|
479
|
+
self.welcome.stop()
|
|
480
|
+
self._line(f"› {text}", "user")
|
|
481
|
+
if self.engine is None:
|
|
482
|
+
refused = self.personal_on_subscription() and not self.project.allow_subscription
|
|
483
|
+
self._line(NOT_CONNECTED_SENSITIVE if refused else NOT_CONNECTED, "error")
|
|
484
|
+
return
|
|
485
|
+
self.busy = True
|
|
486
|
+
self.turn += 1
|
|
487
|
+
self.started = time.monotonic()
|
|
488
|
+
self._set_status("Thinking")
|
|
489
|
+
self._part("#chat-status").display = True
|
|
490
|
+
if self.timer is None:
|
|
491
|
+
self.timer = self.set_interval(1 / 12, self._tick)
|
|
492
|
+
self._turn(self.engine, text, self.turn)
|
|
493
|
+
|
|
494
|
+
def _unsent(self, text: str) -> None:
|
|
495
|
+
"""A message that was not sent (the question before it was cancelled) goes back into an empty prompt."""
|
|
496
|
+
if not self.prompt.value:
|
|
497
|
+
self.prompt.value = text
|
|
498
|
+
self.prompt.focus()
|
|
499
|
+
|
|
500
|
+
def _turn(self, engine: Engine, text: str, turn: int) -> None:
|
|
501
|
+
# A daemon thread, not a Textual thread worker: asyncio joins its executor's threads at shutdown, so a turn
|
|
502
|
+
# that can't be interrupted (an API call, an in-process scan) would keep the process alive after quitting.
|
|
503
|
+
threading.Thread(target=self._run_turn, args=(engine, text, turn), name="evaporator-turn", daemon=True).start()
|
|
504
|
+
|
|
505
|
+
def _run_turn(self, engine: Engine, text: str, turn: int) -> None:
|
|
506
|
+
workdir = self.root / ".evaporator"
|
|
507
|
+
before: Optional[dict] = None
|
|
508
|
+
reports: "list[dict]" = []
|
|
509
|
+
try:
|
|
510
|
+
before = report_stamps(workdir)
|
|
511
|
+
engine.send(text, lambda event: self._post(self._event, engine, event))
|
|
512
|
+
except Exception as exc: # an engine bug: one line, never a traceback
|
|
513
|
+
self._post(self._event, engine, Event("error", one_line(exc)))
|
|
514
|
+
finally:
|
|
515
|
+
try: # a scan finished during the turn: show its card; nothing here may keep the turn from ending
|
|
516
|
+
reports = changed_reports(workdir, before) if before is not None else []
|
|
517
|
+
except Exception:
|
|
518
|
+
reports = []
|
|
519
|
+
self._post(self._turn_done, engine, turn, reports)
|
|
520
|
+
|
|
521
|
+
def _event(self, engine: Engine, event: Event) -> None:
|
|
522
|
+
if engine is not self.engine:
|
|
523
|
+
return # a closed engine's late events
|
|
524
|
+
if event.kind == "text":
|
|
525
|
+
self._add(Reply(event.text))
|
|
526
|
+
elif event.kind == "tool" and event.tool == "scan" and figure_fits(self):
|
|
527
|
+
self._end_scan() # only one animation at a time
|
|
528
|
+
self.scanning = self._add(ScanFigure(event.text, event.tool))
|
|
529
|
+
self._set_status(event.text)
|
|
530
|
+
self._part("#chat-status").display = False # the figure carries the status and the seconds
|
|
531
|
+
elif event.kind == "tool":
|
|
532
|
+
self._line(f"{event.text}…", "tool").tool = event.tool
|
|
533
|
+
self._set_status(event.text)
|
|
534
|
+
elif event.kind == "tool_done" and self.scanning is not None and event.tool == self.scanning.tool:
|
|
535
|
+
self._end_scan(" done")
|
|
536
|
+
self._set_status("Thinking")
|
|
537
|
+
elif event.kind == "tool_done":
|
|
538
|
+
pending = [w for w in self.screen_stack[0].query(".chat-tool") if getattr(w, "tool", None) == event.tool and not w.plain.endswith(" done")]
|
|
539
|
+
if pending:
|
|
540
|
+
pending[-1].set(pending[-1].plain + " done")
|
|
541
|
+
self._set_status("Thinking")
|
|
542
|
+
else:
|
|
543
|
+
self._line(event.text, "error")
|
|
544
|
+
|
|
545
|
+
def _turn_done(self, engine: Engine, turn: int, reports: "list[dict]") -> None:
|
|
546
|
+
if turn == self.turn: # by turn, not engine: a turn whose engine was replaced still frees the prompt
|
|
547
|
+
self.busy = False
|
|
548
|
+
self.status = ""
|
|
549
|
+
self._part("#chat-status").display = False
|
|
550
|
+
if self.timer is not None:
|
|
551
|
+
self.timer.stop()
|
|
552
|
+
self.timer = None
|
|
553
|
+
self._end_scan()
|
|
554
|
+
if engine is not self.engine:
|
|
555
|
+
return # a closed engine's turn: nothing of it is shown or approved
|
|
556
|
+
self._cards(reports)
|
|
557
|
+
self._check_gate()
|
|
558
|
+
|
|
559
|
+
def _end_scan(self, suffix: str = "") -> None:
|
|
560
|
+
"""Stop the scan animation and put the plain tool line in its place ("… done" once the tool finished)."""
|
|
561
|
+
figure, self.scanning = self.scanning, None
|
|
562
|
+
if figure is None:
|
|
563
|
+
return
|
|
564
|
+
figure.stop()
|
|
565
|
+
self._part("#chat-status").display = self.busy
|
|
566
|
+
if figure.is_attached:
|
|
567
|
+
line = Line(f"{figure.message}…{suffix}", "chat-tool")
|
|
568
|
+
line.tool = figure.tool
|
|
569
|
+
transcript = self._part("#transcript", VerticalScroll)
|
|
570
|
+
transcript.mount(line, before=figure)
|
|
571
|
+
figure.remove()
|
|
572
|
+
transcript.scroll_end(animate=False)
|
|
573
|
+
|
|
574
|
+
def _stop_animations(self) -> None:
|
|
575
|
+
for figure in (self.welcome, self.scanning):
|
|
576
|
+
if figure is not None:
|
|
577
|
+
figure.stop()
|
|
578
|
+
if self.timer is not None:
|
|
579
|
+
self.timer.stop()
|
|
580
|
+
self.timer = None
|
|
581
|
+
|
|
582
|
+
def _set_status(self, text: str) -> None:
|
|
583
|
+
self.status = text
|
|
584
|
+
self._tick()
|
|
585
|
+
|
|
586
|
+
def _tick(self) -> None:
|
|
587
|
+
elapsed = time.monotonic() - self.started
|
|
588
|
+
frame = SPINNER[int(elapsed * 12) % len(SPINNER)]
|
|
589
|
+
self._part("#chat-status", Static).update(Text(f"{frame} {self.status}… {int(elapsed)}s"))
|
|
590
|
+
|
|
591
|
+
# -- quitting ----------------------------------------------------------------------------------------------------
|
|
592
|
+
|
|
593
|
+
def exit(self, *args, **kwargs) -> None:
|
|
594
|
+
self._stop_animations()
|
|
595
|
+
self._teardown()
|
|
596
|
+
super().exit(*args, **kwargs)
|
|
597
|
+
|
|
598
|
+
async def run_async(self, *args, **kwargs):
|
|
599
|
+
try:
|
|
600
|
+
return await super().run_async(*args, **kwargs)
|
|
601
|
+
finally:
|
|
602
|
+
self._teardown()
|
|
603
|
+
|
|
604
|
+
def _teardown(self) -> None:
|
|
605
|
+
"""Close the engine (kills its child), kill any other agent child, delete the gate file. Safe to repeat."""
|
|
606
|
+
self.quitting = True
|
|
607
|
+
self._stop_engine()
|
|
608
|
+
kill_all()
|
|
609
|
+
for path in self.gate_paths:
|
|
610
|
+
self._drop_gate(path)
|
|
611
|
+
|
|
612
|
+
@staticmethod
|
|
613
|
+
def _drop_gate(path: Optional[Path]) -> None:
|
|
614
|
+
if path is not None:
|
|
615
|
+
try:
|
|
616
|
+
path.unlink(missing_ok=True)
|
|
617
|
+
except OSError:
|
|
618
|
+
pass
|