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/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()]