euron-code 0.1.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.
- euron_code/__init__.py +3 -0
- euron_code/__main__.py +4 -0
- euron_code/agent.py +595 -0
- euron_code/api.py +364 -0
- euron_code/auth.py +115 -0
- euron_code/cli.py +351 -0
- euron_code/config.py +207 -0
- euron_code/context.py +131 -0
- euron_code/credentials.py +197 -0
- euron_code/doctor.py +88 -0
- euron_code/keys.py +92 -0
- euron_code/memory.py +97 -0
- euron_code/permissions.py +245 -0
- euron_code/prompts.py +98 -0
- euron_code/repl.py +425 -0
- euron_code/sessions.py +186 -0
- euron_code/tools/__init__.py +33 -0
- euron_code/tools/base.py +167 -0
- euron_code/tools/db.py +368 -0
- euron_code/tools/fs.py +412 -0
- euron_code/tools/ignore.py +75 -0
- euron_code/tools/search.py +204 -0
- euron_code/tools/shell.py +214 -0
- euron_code/tools/task.py +41 -0
- euron_code/tools/todo.py +64 -0
- euron_code/tools/web.py +112 -0
- euron_code/ui.py +269 -0
- euron_code/usage_view.py +108 -0
- euron_code-0.1.0.dist-info/METADATA +278 -0
- euron_code-0.1.0.dist-info/RECORD +33 -0
- euron_code-0.1.0.dist-info/WHEEL +4 -0
- euron_code-0.1.0.dist-info/entry_points.txt +2 -0
- euron_code-0.1.0.dist-info/licenses/LICENSE +23 -0
euron_code/repl.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Interactive session: prompt, slash commands, status line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from rich.markup import escape
|
|
14
|
+
|
|
15
|
+
from euron_code import __version__, api, auth, config, memory, prompts, sessions
|
|
16
|
+
from euron_code.agent import Agent, TurnResult
|
|
17
|
+
from euron_code.usage_view import render_account, render_usage, short, status_line
|
|
18
|
+
|
|
19
|
+
SLASH_COMMANDS = {
|
|
20
|
+
"/help": "Show this help",
|
|
21
|
+
"/login": "Log in again (device flow in the browser)",
|
|
22
|
+
"/logout": "Remove the saved key from this computer",
|
|
23
|
+
"/usage": "Plan, tokens today, limits and last 30 days",
|
|
24
|
+
"/model": "Show or switch the model: /model [id]",
|
|
25
|
+
"/compact": "Summarise the conversation to free context: /compact [focus]",
|
|
26
|
+
"/clear": "Start a fresh conversation (new session)",
|
|
27
|
+
"/memory": "Show memory files; /memory edit opens EURON.md",
|
|
28
|
+
"/init": "Explore the repo and write EURON.md",
|
|
29
|
+
"/cost": "Tokens used in this session",
|
|
30
|
+
"/permissions": "Show rules; /permissions mode <default|acceptEdits|yolo>, allow|deny <rule>",
|
|
31
|
+
"/thinking": "Reasoning display: /thinking show|collapse|hide",
|
|
32
|
+
"/todos": "Show the current todo list",
|
|
33
|
+
"/doctor": "Check setup and connectivity",
|
|
34
|
+
"/exit": "Quit (also /quit, Ctrl-D)",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Repl:
|
|
39
|
+
def __init__(self, agent: Agent, client: api.Client, ui, me: Optional[Dict[str, Any]],
|
|
40
|
+
session: sessions.SessionWriter, pt_io: Optional[tuple] = None):
|
|
41
|
+
self.agent = agent
|
|
42
|
+
self.pt_io = pt_io # (input, output) for prompt_toolkit; tests only
|
|
43
|
+
self.client = client
|
|
44
|
+
self.ui = ui
|
|
45
|
+
self.me = me
|
|
46
|
+
self.session = session
|
|
47
|
+
self.me_base_tokens = 0
|
|
48
|
+
self._prompt = None
|
|
49
|
+
agent.on_usage = self._usage_changed
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------- status
|
|
52
|
+
def _usage_changed(self) -> None:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
def remaining_today(self) -> Optional[int]:
|
|
56
|
+
if not self.me:
|
|
57
|
+
return None
|
|
58
|
+
rem = (self.me.get("quota") or {}).get("remainingToday")
|
|
59
|
+
if rem is None:
|
|
60
|
+
return None
|
|
61
|
+
return max(0, int(rem) - (self.agent.tokens.session_total - self.me_base_tokens))
|
|
62
|
+
|
|
63
|
+
def refresh_me(self, force: bool = False) -> None:
|
|
64
|
+
try:
|
|
65
|
+
before = self.client.me_fetched_at
|
|
66
|
+
me = self.client.me(use_cache=not force)
|
|
67
|
+
if self.client.me_fetched_at != before or force:
|
|
68
|
+
self.me_base_tokens = self.agent.tokens.session_total
|
|
69
|
+
self.me = me
|
|
70
|
+
except api.EuronError:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def status(self) -> str:
|
|
74
|
+
me = None
|
|
75
|
+
if self.me:
|
|
76
|
+
me = dict(self.me)
|
|
77
|
+
q = dict(me.get("quota") or {})
|
|
78
|
+
if self.remaining_today() is not None:
|
|
79
|
+
q["remainingToday"] = self.remaining_today()
|
|
80
|
+
me["quota"] = q
|
|
81
|
+
pct = self.agent.context_usage() * 100 // max(1, self.agent.window)
|
|
82
|
+
return status_line(self.agent.model, self.agent.tokens.session_total, me, self.agent.permissions.mode, pct)
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------- input
|
|
85
|
+
def _make_prompt(self):
|
|
86
|
+
if self.pt_io is None and not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
87
|
+
return None
|
|
88
|
+
try:
|
|
89
|
+
from prompt_toolkit import PromptSession
|
|
90
|
+
from prompt_toolkit.completion import WordCompleter
|
|
91
|
+
from prompt_toolkit.history import FileHistory
|
|
92
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
93
|
+
|
|
94
|
+
kb = KeyBindings()
|
|
95
|
+
|
|
96
|
+
@kb.add("escape", "enter")
|
|
97
|
+
def _(event):
|
|
98
|
+
event.current_buffer.insert_text("\n")
|
|
99
|
+
|
|
100
|
+
hist = config.ensure_home() / "history"
|
|
101
|
+
return PromptSession(
|
|
102
|
+
history=FileHistory(str(hist)),
|
|
103
|
+
completer=WordCompleter(list(SLASH_COMMANDS) + ["/quit"], sentence=True),
|
|
104
|
+
complete_while_typing=False,
|
|
105
|
+
key_bindings=kb,
|
|
106
|
+
bottom_toolbar=lambda: " " + self.status() + " (Esc+Enter: newline, Esc: interrupt)",
|
|
107
|
+
**({"input": self.pt_io[0], "output": self.pt_io[1]} if self.pt_io else {}),
|
|
108
|
+
)
|
|
109
|
+
except Exception:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def read_input(self) -> Optional[str]:
|
|
113
|
+
if self._prompt is None:
|
|
114
|
+
self._prompt = self._make_prompt() or False
|
|
115
|
+
if self._prompt:
|
|
116
|
+
try:
|
|
117
|
+
return self._prompt.prompt("> ")
|
|
118
|
+
except KeyboardInterrupt:
|
|
119
|
+
return ""
|
|
120
|
+
except EOFError:
|
|
121
|
+
return None
|
|
122
|
+
try:
|
|
123
|
+
self.ui.dim(self.status())
|
|
124
|
+
return input("> ")
|
|
125
|
+
except KeyboardInterrupt:
|
|
126
|
+
print()
|
|
127
|
+
return ""
|
|
128
|
+
except EOFError:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------- main loop
|
|
132
|
+
def run(self, initial: Optional[str] = None) -> int:
|
|
133
|
+
self.banner()
|
|
134
|
+
pending = initial
|
|
135
|
+
while True:
|
|
136
|
+
text = pending if pending is not None else self.read_input()
|
|
137
|
+
pending = None
|
|
138
|
+
if text is None:
|
|
139
|
+
self.ui.dim("Bye.")
|
|
140
|
+
return 0
|
|
141
|
+
text = text.strip()
|
|
142
|
+
if not text:
|
|
143
|
+
continue
|
|
144
|
+
if text.endswith("\\") and self._prompt is False:
|
|
145
|
+
# simple multi-line for plain input(): end a line with backslash
|
|
146
|
+
lines = [text[:-1]]
|
|
147
|
+
while True:
|
|
148
|
+
try:
|
|
149
|
+
more = input("... ")
|
|
150
|
+
except EOFError:
|
|
151
|
+
break
|
|
152
|
+
if more.endswith("\\"):
|
|
153
|
+
lines.append(more[:-1])
|
|
154
|
+
continue
|
|
155
|
+
lines.append(more)
|
|
156
|
+
break
|
|
157
|
+
text = "\n".join(lines)
|
|
158
|
+
if text.startswith("/"):
|
|
159
|
+
try:
|
|
160
|
+
if self.slash(text) == "exit":
|
|
161
|
+
return 0
|
|
162
|
+
except api.NotEntitledError:
|
|
163
|
+
self.ui.exact(api.NO_PLUS_MESSAGE)
|
|
164
|
+
return 3
|
|
165
|
+
except api.EuronError as e:
|
|
166
|
+
self.ui.error(e.message)
|
|
167
|
+
continue
|
|
168
|
+
if text.startswith("#") and len(text) > 1:
|
|
169
|
+
self.add_memory(text[1:].strip())
|
|
170
|
+
continue
|
|
171
|
+
try:
|
|
172
|
+
code = self.turn(text)
|
|
173
|
+
except KeyboardInterrupt:
|
|
174
|
+
self.ui.warn("Interrupted.")
|
|
175
|
+
continue
|
|
176
|
+
if code is not None:
|
|
177
|
+
return code
|
|
178
|
+
|
|
179
|
+
def banner(self) -> None:
|
|
180
|
+
self.ui.info(f"[bold]Euron Code[/bold] {__version__} [dim]{escape(str(self.agent.root))}[/dim]")
|
|
181
|
+
if self.me:
|
|
182
|
+
access = self.me.get("access") or {}
|
|
183
|
+
q = self.me.get("quota") or {}
|
|
184
|
+
totals = self.me.get("totals") or {}
|
|
185
|
+
self.ui.dim(f"{api.plan_label(access)} | today {short(q.get('usedToday'))} / {short(q.get('dailyTokenLimit'))} tokens"
|
|
186
|
+
f" | all time {short(totals.get('allTimeTokens'))} tokens | model {self.agent.model}")
|
|
187
|
+
if self.agent.memory_files:
|
|
188
|
+
self.ui.dim("Memory: " + ", ".join(f.path.name + f" ({f.scope})" for f in self.agent.memory_files))
|
|
189
|
+
if self.agent.permissions.mode == "yolo":
|
|
190
|
+
self.ui.warn("Permission prompts are OFF (--yolo). Commands and edits run without asking.")
|
|
191
|
+
self.ui.dim("Type /help for commands. Esc or Ctrl-C interrupts a reply; Ctrl-D exits.")
|
|
192
|
+
|
|
193
|
+
def turn(self, text: str) -> Optional[int]:
|
|
194
|
+
res: TurnResult = self.agent.run_turn(text)
|
|
195
|
+
self.refresh_me()
|
|
196
|
+
if res.error is not None:
|
|
197
|
+
return self.handle_error(res.error)
|
|
198
|
+
if res.stop_reason == "max_turns":
|
|
199
|
+
self.ui.warn("Stopped at the step limit.")
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
def handle_error(self, e: api.EuronError) -> Optional[int]:
|
|
203
|
+
if isinstance(e, api.NotEntitledError):
|
|
204
|
+
self.ui.exact(api.NO_PLUS_MESSAGE)
|
|
205
|
+
return 3
|
|
206
|
+
if isinstance(e, api.AuthError):
|
|
207
|
+
self.ui.error(f"{e.message}. Your key is missing, revoked or expired. Run /login (or `euron login`).")
|
|
208
|
+
return None
|
|
209
|
+
if isinstance(e, api.QuotaExceededError):
|
|
210
|
+
self.refresh_me(force=True)
|
|
211
|
+
from euron_code.usage_view import local_time
|
|
212
|
+
resets = local_time(((self.me or {}).get("quota") or {}).get("resetsAt"))
|
|
213
|
+
self.ui.error(f"You have used today's Euron Code token allowance. It resets at {resets}.")
|
|
214
|
+
return None
|
|
215
|
+
self.ui.error(e.message)
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------- memory
|
|
219
|
+
def add_memory(self, note: str) -> None:
|
|
220
|
+
if not note:
|
|
221
|
+
return
|
|
222
|
+
ans = self.ui.ask_text("Save to (p)roject EURON.md or (u)ser ~/.euron/EURON.md? [p] ").strip().lower()
|
|
223
|
+
scope = "user" if ans.startswith("u") else "project"
|
|
224
|
+
path = memory.append_note(note, self.agent.root, scope)
|
|
225
|
+
self.agent.reload_system()
|
|
226
|
+
self.ui.dim(f"Saved to {path}")
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------- slash commands
|
|
229
|
+
def slash(self, line: str) -> Optional[str]:
|
|
230
|
+
parts = line.split(maxsplit=1)
|
|
231
|
+
cmd, arg = parts[0].lower(), (parts[1].strip() if len(parts) > 1 else "")
|
|
232
|
+
if cmd in ("/exit", "/quit"):
|
|
233
|
+
return "exit"
|
|
234
|
+
if cmd == "/help":
|
|
235
|
+
for k, v in SLASH_COMMANDS.items():
|
|
236
|
+
self.ui.info(f" [bold]{k:<13}[/bold] {escape(v)}")
|
|
237
|
+
self.ui.info(" [bold]# note[/bold] Add a note to memory (EURON.md)")
|
|
238
|
+
self.ui.dim(" Esc / Ctrl-C interrupts a reply. Esc+Enter inserts a newline.")
|
|
239
|
+
return None
|
|
240
|
+
if cmd == "/usage":
|
|
241
|
+
me = self.client.me(use_cache=False)
|
|
242
|
+
self.me = me
|
|
243
|
+
self.me_base_tokens = self.agent.tokens.session_total
|
|
244
|
+
usage = None
|
|
245
|
+
try:
|
|
246
|
+
usage = self.client.usage(30)
|
|
247
|
+
except api.EuronError as e:
|
|
248
|
+
self.ui.warn(f"Could not load daily usage: {e.message}")
|
|
249
|
+
render_usage(self.ui, me, usage)
|
|
250
|
+
self.ui.dim(f"This session: {self.agent.tokens.session_total:,} tokens")
|
|
251
|
+
return None
|
|
252
|
+
if cmd == "/cost":
|
|
253
|
+
t = self.agent.tokens
|
|
254
|
+
self.ui.info(f"Session tokens: {t.session_total:,} (prompt {t.session_prompt:,}, completion "
|
|
255
|
+
f"{t.session_completion:,}) over {t.requests} requests")
|
|
256
|
+
if t.session_cached:
|
|
257
|
+
self.ui.info(f"Prompt tokens served from the gateway's prefix cache: {t.session_cached:,}")
|
|
258
|
+
self.ui.info(f"Context now: ~{self.agent.context_usage():,} of {self.agent.window:,} tokens")
|
|
259
|
+
rem = self.remaining_today()
|
|
260
|
+
if rem is not None:
|
|
261
|
+
self.ui.info(f"Remaining today: ~{rem:,} tokens")
|
|
262
|
+
self.ui.dim("Euron Code is included with your plan; tokens count toward the daily allowance only.")
|
|
263
|
+
return None
|
|
264
|
+
if cmd == "/model":
|
|
265
|
+
return self._model(arg)
|
|
266
|
+
if cmd == "/compact":
|
|
267
|
+
self.agent.compact(force=True, instructions=arg or None, manual=True)
|
|
268
|
+
return None
|
|
269
|
+
if cmd == "/clear":
|
|
270
|
+
self.agent.clear()
|
|
271
|
+
self.session = sessions.SessionWriter(self.agent.root, model=self.agent.model)
|
|
272
|
+
self.agent.session = self.session
|
|
273
|
+
self.ui.dim(f"Started a new conversation (session {self.session.id}).")
|
|
274
|
+
return None
|
|
275
|
+
if cmd == "/memory":
|
|
276
|
+
return self._memory(arg)
|
|
277
|
+
if cmd == "/init":
|
|
278
|
+
self.turn(prompts.INIT_PROMPT)
|
|
279
|
+
self.agent.reload_system()
|
|
280
|
+
return None
|
|
281
|
+
if cmd == "/permissions":
|
|
282
|
+
return self._permissions(arg)
|
|
283
|
+
if cmd == "/thinking":
|
|
284
|
+
if arg in ("show", "collapse", "hide"):
|
|
285
|
+
self.ui.reasoning_mode = arg
|
|
286
|
+
config.config_set("reasoning", arg)
|
|
287
|
+
else:
|
|
288
|
+
order = ["collapse", "show", "hide"]
|
|
289
|
+
self.ui.reasoning_mode = order[(order.index(self.ui.reasoning_mode) + 1) % 3] \
|
|
290
|
+
if self.ui.reasoning_mode in order else "collapse"
|
|
291
|
+
self.ui.dim(f"Reasoning display: {self.ui.reasoning_mode}")
|
|
292
|
+
return None
|
|
293
|
+
if cmd == "/todos":
|
|
294
|
+
self.ui.show_todos(self.agent.ctx.todos)
|
|
295
|
+
return None
|
|
296
|
+
if cmd == "/doctor":
|
|
297
|
+
from euron_code.doctor import doctor
|
|
298
|
+
doctor(self.ui)
|
|
299
|
+
return None
|
|
300
|
+
if cmd == "/login":
|
|
301
|
+
me = auth.login(self.ui, self.client.base_url)
|
|
302
|
+
from euron_code import credentials
|
|
303
|
+
cred = credentials.load(self.client.base_url)
|
|
304
|
+
if cred:
|
|
305
|
+
self.client.api_key = cred.api_key
|
|
306
|
+
self.me = me
|
|
307
|
+
return None
|
|
308
|
+
if cmd == "/logout":
|
|
309
|
+
auth.logout(self.ui, self.client.base_url)
|
|
310
|
+
self.ui.dim("Run /login to sign in again, or /exit.")
|
|
311
|
+
self.client.api_key = None
|
|
312
|
+
return None
|
|
313
|
+
self.ui.warn(f"Unknown command {cmd}. Type /help.")
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
def _model(self, arg: str) -> None:
|
|
317
|
+
models = []
|
|
318
|
+
try:
|
|
319
|
+
models = self.client.models()
|
|
320
|
+
except api.EuronError as e:
|
|
321
|
+
self.ui.warn(f"Could not list models: {e.message}")
|
|
322
|
+
if not arg:
|
|
323
|
+
self.ui.info(f"Current model: [bold]{self.agent.model}[/bold]")
|
|
324
|
+
for m in models:
|
|
325
|
+
mark = "*" if m.get("id") == self.agent.model else " "
|
|
326
|
+
extra = []
|
|
327
|
+
for k in ("context_length", "max_model_len", "contextWindow"):
|
|
328
|
+
if m.get(k):
|
|
329
|
+
extra.append(f"context {m[k]}")
|
|
330
|
+
break
|
|
331
|
+
self.ui.info(f" {mark} {m.get('id')} [dim]{' '.join(extra)}[/dim]")
|
|
332
|
+
self.ui.dim("Switch with /model <id>.")
|
|
333
|
+
return None
|
|
334
|
+
ids = [m.get("id") for m in models]
|
|
335
|
+
if ids and arg not in ids:
|
|
336
|
+
self.ui.warn(f"Unknown model {arg!r}. Available: {', '.join(i for i in ids if i)}")
|
|
337
|
+
return None
|
|
338
|
+
self.agent.model = arg
|
|
339
|
+
self.ui.info(f"Model set to [bold]{escape(arg)}[/bold] for this session "
|
|
340
|
+
f"(make it the default with `euron config set model {escape(arg)}`).")
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
def _memory(self, arg: str) -> None:
|
|
344
|
+
if arg.startswith("edit"):
|
|
345
|
+
scope = "user" if "user" in arg else "project"
|
|
346
|
+
path = memory.user_file() if scope == "user" else memory.project_file(self.agent.root)
|
|
347
|
+
if not path.exists():
|
|
348
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
path.write_text("# Notes for Euron Code\n\n", encoding="utf-8")
|
|
350
|
+
open_in_editor(path, self.ui)
|
|
351
|
+
self.agent.reload_system()
|
|
352
|
+
self.ui.dim("Memory reloaded.")
|
|
353
|
+
return None
|
|
354
|
+
files = memory.load(self.agent.root)
|
|
355
|
+
if not files:
|
|
356
|
+
self.ui.info("No memory files yet. Add a note with `# <note>`, run /init, or /memory edit.")
|
|
357
|
+
for f in files:
|
|
358
|
+
self.ui.info(f"[bold]{escape(str(f.path))}[/bold] ({f.scope})")
|
|
359
|
+
self.ui.plain(f.text[:3000])
|
|
360
|
+
self.ui.info("")
|
|
361
|
+
self.ui.dim("Edit with /memory edit (project) or /memory edit user.")
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
def _permissions(self, arg: str) -> None:
|
|
365
|
+
p = self.agent.permissions
|
|
366
|
+
bits = arg.split(maxsplit=1)
|
|
367
|
+
if bits and bits[0] == "mode" and len(bits) > 1:
|
|
368
|
+
if bits[1] not in ("default", "acceptEdits", "yolo"):
|
|
369
|
+
self.ui.warn("Modes: default, acceptEdits, yolo")
|
|
370
|
+
return None
|
|
371
|
+
p.mode = bits[1]
|
|
372
|
+
self.ui.info(f"Permission mode: {p.mode}")
|
|
373
|
+
return None
|
|
374
|
+
if bits and bits[0] in ("allow", "deny") and len(bits) > 1:
|
|
375
|
+
path = config.add_permission_rule(self.agent.root, bits[0], bits[1])
|
|
376
|
+
(p.allow if bits[0] == "allow" else p.deny).append(bits[1])
|
|
377
|
+
self.ui.info(f"Added {bits[0]} rule {escape(bits[1])} ({path})")
|
|
378
|
+
return None
|
|
379
|
+
self.ui.info(f"Mode: [bold]{p.mode}[/bold] (project root {escape(str(p.root))})")
|
|
380
|
+
self.ui.info("Allow rules: " + (escape(", ".join(p.allow)) or "(none)"))
|
|
381
|
+
self.ui.info("Deny rules: " + (escape(", ".join(p.deny)) or "(none)"))
|
|
382
|
+
self.ui.info("Session: " + (escape(", ".join(p.session_allow)) or "(none)"))
|
|
383
|
+
self.ui.dim("Defaults: reads inside the project are allowed; edits, commands and web fetches ask.")
|
|
384
|
+
self.ui.dim("Change: /permissions mode <default|acceptEdits|yolo>, /permissions allow 'Bash(npm test:*)'")
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def open_in_editor(path: Path, ui) -> None:
|
|
389
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
|
|
390
|
+
if not editor:
|
|
391
|
+
if shutil.which("code"):
|
|
392
|
+
editor = "code --wait"
|
|
393
|
+
elif os.name == "nt":
|
|
394
|
+
editor = "notepad"
|
|
395
|
+
else:
|
|
396
|
+
editor = shutil.which("nano") and "nano" or "vi"
|
|
397
|
+
try:
|
|
398
|
+
import shlex
|
|
399
|
+
argv = shlex.split(editor, posix=(os.name != "nt")) + [str(path)]
|
|
400
|
+
subprocess.run(argv, shell=(os.name == "nt" and editor.startswith("code")))
|
|
401
|
+
except Exception as e:
|
|
402
|
+
ui.warn(f"Could not open an editor ({e}). Edit {path} yourself.")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def pick_session(root: Path, ui) -> Optional[sessions.SessionInfo]:
|
|
406
|
+
items = sessions.list_sessions(root)[:20]
|
|
407
|
+
if not items:
|
|
408
|
+
ui.warn("No saved sessions for this project.")
|
|
409
|
+
return None
|
|
410
|
+
for i, s in enumerate(items, 1):
|
|
411
|
+
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(s.modified))
|
|
412
|
+
ui.info(f" [bold]{i:>2}[/bold] {when} [dim]{s.messages:>3} msgs[/dim] {escape(s.first_prompt or '(empty)')}")
|
|
413
|
+
ans = ui.ask_text("Resume which session? [1] ").strip()
|
|
414
|
+
if not ans:
|
|
415
|
+
return items[0]
|
|
416
|
+
try:
|
|
417
|
+
n = int(ans)
|
|
418
|
+
if 1 <= n <= len(items):
|
|
419
|
+
return items[n - 1]
|
|
420
|
+
except ValueError:
|
|
421
|
+
found = sessions.find(root, ans)
|
|
422
|
+
if found:
|
|
423
|
+
return found
|
|
424
|
+
ui.warn("No such session.")
|
|
425
|
+
return None
|
euron_code/sessions.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Session transcripts: ~/.euron/sessions/<project-hash>/<id>.jsonl
|
|
2
|
+
|
|
3
|
+
Records (one JSON object per line):
|
|
4
|
+
{"type": "meta", "id", "root", "created", "model", "version"}
|
|
5
|
+
{"type": "message", "message": {...OpenAI chat message...}}
|
|
6
|
+
{"type": "compact", "messages": [...full replacement history, without system...]}
|
|
7
|
+
{"type": "todos", "todos": [...]}
|
|
8
|
+
The system prompt is never stored (it is rebuilt on resume, so memory changes apply).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as _dt
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import uuid
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
from euron_code import __version__, config
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def project_hash(root: Path) -> str:
|
|
26
|
+
s = str(root.resolve())
|
|
27
|
+
if os.name == "nt":
|
|
28
|
+
s = s.lower()
|
|
29
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()[:16]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sessions_dir(root: Path) -> Path:
|
|
33
|
+
return config.euron_home() / "sessions" / project_hash(root)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def new_id() -> str:
|
|
37
|
+
ts = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
38
|
+
return f"{ts}-{uuid.uuid4().hex[:6]}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class SessionInfo:
|
|
43
|
+
id: str
|
|
44
|
+
path: Path
|
|
45
|
+
modified: float
|
|
46
|
+
first_prompt: str
|
|
47
|
+
messages: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class LoadedSession:
|
|
52
|
+
id: str
|
|
53
|
+
messages: List[Dict[str, Any]] = field(default_factory=list)
|
|
54
|
+
todos: List[Dict[str, Any]] = field(default_factory=list)
|
|
55
|
+
model: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SessionWriter:
|
|
59
|
+
def __init__(self, root: Path, session_id: Optional[str] = None, model: Optional[str] = None):
|
|
60
|
+
self.root = root
|
|
61
|
+
self.id = session_id or new_id()
|
|
62
|
+
self.path = sessions_dir(root) / f"{self.id}.jsonl"
|
|
63
|
+
self._started = self.path.exists()
|
|
64
|
+
self.model = model
|
|
65
|
+
|
|
66
|
+
def _write(self, rec: Dict[str, Any]) -> None:
|
|
67
|
+
try:
|
|
68
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
if not self._started:
|
|
70
|
+
self._started = True
|
|
71
|
+
meta = {"type": "meta", "id": self.id, "root": str(self.root), "version": __version__,
|
|
72
|
+
"created": _dt.datetime.now().isoformat(timespec="seconds"), "model": self.model}
|
|
73
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
74
|
+
f.write(json.dumps(meta, ensure_ascii=False) + "\n")
|
|
75
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
76
|
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
77
|
+
except OSError:
|
|
78
|
+
pass # a transcript problem must never break the session
|
|
79
|
+
|
|
80
|
+
def message(self, msg: Dict[str, Any]) -> None:
|
|
81
|
+
self._write({"type": "message", "message": msg})
|
|
82
|
+
|
|
83
|
+
def compact(self, messages: List[Dict[str, Any]]) -> None:
|
|
84
|
+
self._write({"type": "compact", "messages": messages})
|
|
85
|
+
|
|
86
|
+
def todos(self, todos: List[Dict[str, Any]]) -> None:
|
|
87
|
+
self._write({"type": "todos", "todos": todos})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_session(path: Path) -> LoadedSession:
|
|
91
|
+
sess = LoadedSession(id=path.stem)
|
|
92
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
93
|
+
for line in f:
|
|
94
|
+
line = line.strip()
|
|
95
|
+
if not line:
|
|
96
|
+
continue
|
|
97
|
+
try:
|
|
98
|
+
rec = json.loads(line)
|
|
99
|
+
except ValueError:
|
|
100
|
+
continue # tolerate a torn last line after a crash
|
|
101
|
+
t = rec.get("type")
|
|
102
|
+
if t == "meta":
|
|
103
|
+
sess.model = rec.get("model")
|
|
104
|
+
elif t == "message" and isinstance(rec.get("message"), dict):
|
|
105
|
+
sess.messages.append(rec["message"])
|
|
106
|
+
elif t == "compact" and isinstance(rec.get("messages"), list):
|
|
107
|
+
sess.messages = list(rec["messages"])
|
|
108
|
+
elif t == "todos" and isinstance(rec.get("todos"), list):
|
|
109
|
+
sess.todos = rec["todos"]
|
|
110
|
+
sess.messages = _repair(sess.messages)
|
|
111
|
+
return sess
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _repair(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
115
|
+
"""Drop a trailing assistant tool-call message whose results were never recorded (crash or
|
|
116
|
+
interrupt mid-turn), so the resumed conversation is valid."""
|
|
117
|
+
out = list(messages)
|
|
118
|
+
while out:
|
|
119
|
+
last = out[-1]
|
|
120
|
+
if last.get("role") == "assistant" and last.get("tool_calls"):
|
|
121
|
+
out.pop()
|
|
122
|
+
continue
|
|
123
|
+
break
|
|
124
|
+
# also ensure every assistant tool_call has results
|
|
125
|
+
fixed: List[Dict[str, Any]] = []
|
|
126
|
+
i = 0
|
|
127
|
+
while i < len(out):
|
|
128
|
+
m = out[i]
|
|
129
|
+
fixed.append(m)
|
|
130
|
+
if m.get("role") == "assistant" and m.get("tool_calls"):
|
|
131
|
+
ids = [tc.get("id") for tc in m["tool_calls"]]
|
|
132
|
+
j = i + 1
|
|
133
|
+
seen = set()
|
|
134
|
+
while j < len(out) and out[j].get("role") == "tool":
|
|
135
|
+
seen.add(out[j].get("tool_call_id"))
|
|
136
|
+
fixed.append(out[j])
|
|
137
|
+
j += 1
|
|
138
|
+
for tid in ids:
|
|
139
|
+
if tid not in seen:
|
|
140
|
+
fixed.append({"role": "tool", "tool_call_id": tid, "content": "[no result recorded]"})
|
|
141
|
+
i = j
|
|
142
|
+
continue
|
|
143
|
+
i += 1
|
|
144
|
+
return fixed
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def list_sessions(root: Path) -> List[SessionInfo]:
|
|
148
|
+
d = sessions_dir(root)
|
|
149
|
+
if not d.is_dir():
|
|
150
|
+
return []
|
|
151
|
+
infos = []
|
|
152
|
+
for p in d.glob("*.jsonl"):
|
|
153
|
+
first = ""
|
|
154
|
+
count = 0
|
|
155
|
+
try:
|
|
156
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
157
|
+
for line in f:
|
|
158
|
+
try:
|
|
159
|
+
rec = json.loads(line)
|
|
160
|
+
except ValueError:
|
|
161
|
+
continue
|
|
162
|
+
if rec.get("type") == "message":
|
|
163
|
+
count += 1
|
|
164
|
+
m = rec["message"]
|
|
165
|
+
if not first and m.get("role") == "user":
|
|
166
|
+
c = str(m.get("content") or "")
|
|
167
|
+
if "</context>" in c:
|
|
168
|
+
c = c.split("</context>", 1)[1]
|
|
169
|
+
first = " ".join(c.split())[:80]
|
|
170
|
+
infos.append(SessionInfo(p.stem, p, p.stat().st_mtime, first, count))
|
|
171
|
+
except OSError:
|
|
172
|
+
continue
|
|
173
|
+
infos.sort(key=lambda s: -s.modified)
|
|
174
|
+
return [s for s in infos if s.messages > 0]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def latest(root: Path) -> Optional[SessionInfo]:
|
|
178
|
+
items = list_sessions(root)
|
|
179
|
+
return items[0] if items else None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def find(root: Path, session_id: str) -> Optional[SessionInfo]:
|
|
183
|
+
for s in list_sessions(root):
|
|
184
|
+
if s.id == session_id or s.id.startswith(session_id):
|
|
185
|
+
return s
|
|
186
|
+
return None
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Tool registry. The order here is the order sent to the model; keep it stable for the
|
|
2
|
+
gateway's prefix cache."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, List
|
|
7
|
+
|
|
8
|
+
from euron_code.tools.base import Tool, ToolContext, ToolError, ToolResult, truncate
|
|
9
|
+
from euron_code.tools.db import DbQuery
|
|
10
|
+
from euron_code.tools.fs import EditFile, ListDir, MultiEdit, ReadFile, WriteFile
|
|
11
|
+
from euron_code.tools.search import Glob, Grep
|
|
12
|
+
from euron_code.tools.shell import Bash
|
|
13
|
+
from euron_code.tools.task import Task
|
|
14
|
+
from euron_code.tools.todo import TodoWrite
|
|
15
|
+
from euron_code.tools.web import WebFetch
|
|
16
|
+
|
|
17
|
+
__all__ = ["Tool", "ToolContext", "ToolError", "ToolResult", "truncate", "default_tools", "read_only_tools"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def default_tools(settings: Dict[str, Any], subagents: bool = True) -> List[Tool]:
|
|
21
|
+
tools: List[Tool] = [
|
|
22
|
+
ReadFile(), WriteFile(), EditFile(), MultiEdit(), ListDir(), Glob(), Grep(), Bash(),
|
|
23
|
+
TodoWrite(), DbQuery(),
|
|
24
|
+
]
|
|
25
|
+
if settings.get("webFetch", True):
|
|
26
|
+
tools.append(WebFetch())
|
|
27
|
+
if subagents:
|
|
28
|
+
tools.append(Task())
|
|
29
|
+
return tools
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_only_tools() -> List[Tool]:
|
|
33
|
+
return [ReadFile(), ListDir(), Glob(), Grep()]
|