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/cli.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Command-line entry point: `euron`.
|
|
2
|
+
|
|
3
|
+
Exit codes:
|
|
4
|
+
0 success | 1 error | 2 bad usage | 3 no Euron Plus / Super 30 access | 4 not logged in / key rejected
|
|
5
|
+
5 daily token allowance used | 6 --max-turns reached | 130 interrupted
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
from euron_code import __version__, api, auth, config, credentials, sessions
|
|
18
|
+
from euron_code.ui import UI, QuietUI
|
|
19
|
+
|
|
20
|
+
EXIT_OK, EXIT_ERROR, EXIT_USAGE, EXIT_NO_PLUS, EXIT_AUTH, EXIT_QUOTA, EXIT_MAX_TURNS, EXIT_INT = 0, 1, 2, 3, 4, 5, 6, 130
|
|
21
|
+
SUBCOMMANDS = ("login", "logout", "whoami", "usage", "doctor", "config")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _fix_windows_console() -> None:
|
|
25
|
+
"""Make sure UTF-8 output does not crash on legacy Windows code pages."""
|
|
26
|
+
for stream in (sys.stdout, sys.stderr):
|
|
27
|
+
try:
|
|
28
|
+
if stream and hasattr(stream, "reconfigure") and (stream.encoding or "").lower() not in ("utf-8", "utf8"):
|
|
29
|
+
stream.reconfigure(errors="replace")
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main_parser() -> argparse.ArgumentParser:
|
|
35
|
+
p = argparse.ArgumentParser(
|
|
36
|
+
prog="euron",
|
|
37
|
+
description="Euron Code: an AI coding agent in your terminal (Euron Plus and Super 30).",
|
|
38
|
+
epilog="Commands: euron login | logout | whoami | usage | doctor | config get|set|unset|list. "
|
|
39
|
+
"Run `euron` with no arguments for an interactive session.",
|
|
40
|
+
)
|
|
41
|
+
p.add_argument("prompt", nargs="?", help="Initial prompt (with -p: the one-shot prompt).")
|
|
42
|
+
p.add_argument("-p", "--print", dest="print_mode", action="store_true",
|
|
43
|
+
help="Non-interactive: run the prompt, print the result, exit.")
|
|
44
|
+
p.add_argument("--output-format", choices=["text", "json"], default="text", help="With -p: text or json.")
|
|
45
|
+
p.add_argument("--max-turns", type=int, default=None, help="Maximum model steps for this run.")
|
|
46
|
+
p.add_argument("--model", default=None, help="Model id (default: your account's default model).")
|
|
47
|
+
p.add_argument("-c", "--continue", dest="cont", action="store_true", help="Continue the latest session in this folder.")
|
|
48
|
+
p.add_argument("-r", "--resume", nargs="?", const="", default=None, metavar="ID",
|
|
49
|
+
help="Resume a session (pick from a list, or give an id).")
|
|
50
|
+
p.add_argument("--yolo", "--dangerously-skip-permissions", dest="yolo", action="store_true",
|
|
51
|
+
help="Do not ask before edits or commands. Only use in a throwaway folder or container.")
|
|
52
|
+
p.add_argument("--permission-mode", choices=["default", "acceptEdits", "yolo"], default=None)
|
|
53
|
+
p.add_argument("--allowed-tools", default=None,
|
|
54
|
+
help="Comma-separated allow rules for this run, e.g. \"Edit,Bash(pytest:*)\".")
|
|
55
|
+
p.add_argument("--verbose", action="store_true", help="With -p: show progress on stderr.")
|
|
56
|
+
p.add_argument("--version", action="version", version=f"euron-code {__version__}")
|
|
57
|
+
return p
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sub_parser(cmd: str) -> argparse.ArgumentParser:
|
|
61
|
+
p = argparse.ArgumentParser(prog=f"euron {cmd}")
|
|
62
|
+
if cmd == "login":
|
|
63
|
+
p.add_argument("--no-browser", action="store_true", help="Print the link instead of opening a browser.")
|
|
64
|
+
if cmd == "usage":
|
|
65
|
+
p.add_argument("--json", action="store_true")
|
|
66
|
+
p.add_argument("--days", type=int, default=30)
|
|
67
|
+
if cmd == "whoami":
|
|
68
|
+
p.add_argument("--json", action="store_true")
|
|
69
|
+
if cmd == "config":
|
|
70
|
+
p.add_argument("action", choices=["get", "set", "unset", "list", "path"])
|
|
71
|
+
p.add_argument("key", nargs="?")
|
|
72
|
+
p.add_argument("value", nargs="?")
|
|
73
|
+
p.add_argument("--project", action="store_true", help="Write .euron/settings.json in this folder.")
|
|
74
|
+
return p
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
78
|
+
_fix_windows_console()
|
|
79
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
80
|
+
try:
|
|
81
|
+
if argv and argv[0] in SUBCOMMANDS:
|
|
82
|
+
return run_subcommand(argv[0], sub_parser(argv[0]).parse_args(argv[1:]))
|
|
83
|
+
args = main_parser().parse_args(argv)
|
|
84
|
+
return run_main(args)
|
|
85
|
+
except KeyboardInterrupt:
|
|
86
|
+
print("\nInterrupted.", file=sys.stderr)
|
|
87
|
+
return EXIT_INT
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------- helpers
|
|
91
|
+
|
|
92
|
+
def _need_client(ui) -> api.Client:
|
|
93
|
+
url = config.base_url()
|
|
94
|
+
cred = credentials.load(url)
|
|
95
|
+
if cred is None:
|
|
96
|
+
raise api.AuthError("You are not logged in. Run `euron login` first.", status=401, code="missing_api_key")
|
|
97
|
+
return api.Client(api_key=cred.api_key, base_url=url)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _report(ui, e: api.EuronError) -> int:
|
|
101
|
+
if isinstance(e, api.NotEntitledError):
|
|
102
|
+
ui.exact(api.NO_PLUS_MESSAGE)
|
|
103
|
+
return EXIT_NO_PLUS
|
|
104
|
+
if isinstance(e, api.AuthError):
|
|
105
|
+
msg = e.message if "euron login" in e.message else f"{e.message}. Run `euron login` to sign in again."
|
|
106
|
+
ui.error(msg)
|
|
107
|
+
return EXIT_AUTH
|
|
108
|
+
if isinstance(e, api.QuotaExceededError):
|
|
109
|
+
ui.error(e.message)
|
|
110
|
+
return EXIT_QUOTA
|
|
111
|
+
ui.error(e.message)
|
|
112
|
+
return EXIT_ERROR
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _check_entitled(me) -> None:
|
|
116
|
+
if not (me.get("access") or {}).get("entitled"):
|
|
117
|
+
raise api.NotEntitledError(api.NO_PLUS_MESSAGE, status=403, code="no_plus_access")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------- subcommands
|
|
121
|
+
|
|
122
|
+
def run_subcommand(cmd: str, args) -> int:
|
|
123
|
+
ui = UI()
|
|
124
|
+
try:
|
|
125
|
+
if cmd == "login":
|
|
126
|
+
auth.login(ui, open_browser=not args.no_browser)
|
|
127
|
+
return EXIT_OK
|
|
128
|
+
if cmd == "logout":
|
|
129
|
+
auth.logout(ui)
|
|
130
|
+
return EXIT_OK
|
|
131
|
+
if cmd == "doctor":
|
|
132
|
+
from euron_code.doctor import doctor
|
|
133
|
+
return doctor(ui)
|
|
134
|
+
if cmd == "config":
|
|
135
|
+
return run_config(ui, args)
|
|
136
|
+
client = _need_client(ui)
|
|
137
|
+
try:
|
|
138
|
+
me = client.me(use_cache=(cmd != "usage"))
|
|
139
|
+
if cmd == "whoami":
|
|
140
|
+
if args.json:
|
|
141
|
+
print(json.dumps(me, indent=2))
|
|
142
|
+
else:
|
|
143
|
+
from euron_code.usage_view import render_account
|
|
144
|
+
render_account(ui, me)
|
|
145
|
+
ui.dim(f"Key {credentials.mask_key(client.api_key)} ({credentials.where(client.base_url)}), "
|
|
146
|
+
f"API {client.base_url}")
|
|
147
|
+
_check_entitled(me)
|
|
148
|
+
return EXIT_OK
|
|
149
|
+
if cmd == "usage":
|
|
150
|
+
_check_entitled(me)
|
|
151
|
+
usage = client.usage(args.days)
|
|
152
|
+
if args.json:
|
|
153
|
+
print(json.dumps({"me": me, "usage": usage}, indent=2))
|
|
154
|
+
else:
|
|
155
|
+
from euron_code.usage_view import render_usage
|
|
156
|
+
render_usage(ui, me, usage)
|
|
157
|
+
return EXIT_OK
|
|
158
|
+
finally:
|
|
159
|
+
client.close()
|
|
160
|
+
except api.EuronError as e:
|
|
161
|
+
return _report(ui, e)
|
|
162
|
+
return EXIT_USAGE
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def run_config(ui, args) -> int:
|
|
166
|
+
root = Path.cwd() if args.project else None
|
|
167
|
+
if args.action == "path":
|
|
168
|
+
ui.plain(str(config.project_settings_path(Path.cwd()) if args.project else config.user_settings_path()))
|
|
169
|
+
return EXIT_OK
|
|
170
|
+
if args.action == "list":
|
|
171
|
+
print(json.dumps(config.load_settings(Path.cwd()), indent=2, sort_keys=True))
|
|
172
|
+
return EXIT_OK
|
|
173
|
+
if not args.key:
|
|
174
|
+
ui.error("A key is required, e.g. `euron config get model`.")
|
|
175
|
+
return EXIT_USAGE
|
|
176
|
+
if args.action == "get":
|
|
177
|
+
val = config.get_path(config.load_settings(Path.cwd()), args.key)
|
|
178
|
+
if args.key.startswith("db.connections") and isinstance(val, (str, dict)):
|
|
179
|
+
from euron_code.tools.db import mask_dsn
|
|
180
|
+
val = mask_dsn(val) if isinstance(val, str) else {k: mask_dsn(str(v)) for k, v in val.items()}
|
|
181
|
+
print(json.dumps(val) if not isinstance(val, str) else val)
|
|
182
|
+
return EXIT_OK
|
|
183
|
+
if args.action == "set":
|
|
184
|
+
if args.value is None:
|
|
185
|
+
ui.error("A value is required, e.g. `euron config set reasoning show`.")
|
|
186
|
+
return EXIT_USAGE
|
|
187
|
+
path = config.config_set(args.key, args.value, root)
|
|
188
|
+
ui.dim(f"Saved {args.key} in {path}")
|
|
189
|
+
return EXIT_OK
|
|
190
|
+
if args.action == "unset":
|
|
191
|
+
path = config.config_unset(args.key, root)
|
|
192
|
+
ui.dim(f"Removed {args.key} from {path}")
|
|
193
|
+
return EXIT_OK
|
|
194
|
+
return EXIT_USAGE
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------- main session
|
|
198
|
+
|
|
199
|
+
def run_main(args) -> int:
|
|
200
|
+
from euron_code.agent import Agent
|
|
201
|
+
from euron_code.permissions import Permissions
|
|
202
|
+
|
|
203
|
+
root = config.find_project_root()
|
|
204
|
+
settings = config.load_settings(root)
|
|
205
|
+
if args.print_mode:
|
|
206
|
+
ui = QuietUI(verbose=args.verbose)
|
|
207
|
+
else:
|
|
208
|
+
ui = UI(reasoning=str(settings.get("reasoning") or "collapse"))
|
|
209
|
+
|
|
210
|
+
prompt = args.prompt
|
|
211
|
+
if args.print_mode:
|
|
212
|
+
piped = ""
|
|
213
|
+
if not sys.stdin.isatty():
|
|
214
|
+
try:
|
|
215
|
+
piped = sys.stdin.read()
|
|
216
|
+
except (OSError, ValueError):
|
|
217
|
+
piped = ""
|
|
218
|
+
if piped.strip():
|
|
219
|
+
prompt = (prompt + "\n\n" if prompt else "") + piped
|
|
220
|
+
if not prompt or not prompt.strip():
|
|
221
|
+
ui.error("-p needs a prompt: euron -p \"explain this repo\"")
|
|
222
|
+
return EXIT_USAGE
|
|
223
|
+
|
|
224
|
+
# credentials + entitlement
|
|
225
|
+
url = config.base_url()
|
|
226
|
+
cred = credentials.load(url)
|
|
227
|
+
if cred is None:
|
|
228
|
+
if args.print_mode or not sys.stdin.isatty():
|
|
229
|
+
ui.error("You are not logged in. Run `euron login` first.")
|
|
230
|
+
return EXIT_AUTH
|
|
231
|
+
ui.info("You are not logged in yet.")
|
|
232
|
+
try:
|
|
233
|
+
auth.login(ui)
|
|
234
|
+
except api.EuronError as e:
|
|
235
|
+
return _report(ui, e)
|
|
236
|
+
cred = credentials.load(url)
|
|
237
|
+
if cred is None:
|
|
238
|
+
return EXIT_AUTH
|
|
239
|
+
client = api.Client(api_key=cred.api_key, base_url=url)
|
|
240
|
+
try:
|
|
241
|
+
try:
|
|
242
|
+
me = client.me()
|
|
243
|
+
_check_entitled(me)
|
|
244
|
+
except api.EuronError as e:
|
|
245
|
+
return _report(ui, e)
|
|
246
|
+
|
|
247
|
+
model = args.model or settings.get("model") or me.get("defaultModel") or "euron-coder"
|
|
248
|
+
perms_cfg = settings.get("permissions") or {}
|
|
249
|
+
mode = "yolo" if args.yolo else (args.permission_mode or perms_cfg.get("defaultMode") or "default")
|
|
250
|
+
allow = list(perms_cfg.get("allow") or [])
|
|
251
|
+
if args.allowed_tools:
|
|
252
|
+
allow += [r.strip() for r in args.allowed_tools.split(",") if r.strip()]
|
|
253
|
+
permissions = Permissions(root, allow=allow, deny=list(perms_cfg.get("deny") or []), mode=mode)
|
|
254
|
+
|
|
255
|
+
# session
|
|
256
|
+
loaded = None
|
|
257
|
+
if args.cont:
|
|
258
|
+
info = sessions.latest(root)
|
|
259
|
+
if info is None:
|
|
260
|
+
ui.warn("No previous session in this folder; starting a new one.")
|
|
261
|
+
else:
|
|
262
|
+
loaded = sessions.load_session(info.path)
|
|
263
|
+
elif args.resume is not None:
|
|
264
|
+
if args.resume:
|
|
265
|
+
info = sessions.find(root, args.resume)
|
|
266
|
+
if info is None:
|
|
267
|
+
ui.error(f"No session {args.resume!r} for this folder.")
|
|
268
|
+
return EXIT_USAGE
|
|
269
|
+
elif args.print_mode:
|
|
270
|
+
info = sessions.latest(root)
|
|
271
|
+
else:
|
|
272
|
+
from euron_code.repl import pick_session
|
|
273
|
+
info = pick_session(root, ui)
|
|
274
|
+
if info is not None:
|
|
275
|
+
loaded = sessions.load_session(info.path)
|
|
276
|
+
session = sessions.SessionWriter(root, session_id=loaded.id if loaded else None, model=model)
|
|
277
|
+
agent = Agent(client, ui, root, settings, permissions, model, session=session, max_turns=args.max_turns)
|
|
278
|
+
if loaded:
|
|
279
|
+
agent.load_history(loaded.messages, loaded.todos)
|
|
280
|
+
if not args.print_mode:
|
|
281
|
+
ui.dim(f"Resumed session {loaded.id} ({len(loaded.messages)} messages).")
|
|
282
|
+
|
|
283
|
+
if args.print_mode:
|
|
284
|
+
return run_print(agent, ui, client, prompt, args, session)
|
|
285
|
+
from euron_code.repl import Repl
|
|
286
|
+
return Repl(agent, client, ui, me, session).run(initial=prompt)
|
|
287
|
+
finally:
|
|
288
|
+
client.close()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def run_print(agent, ui, client, prompt: str, args, session) -> int:
|
|
292
|
+
res = agent.run_turn(prompt)
|
|
293
|
+
code = EXIT_OK
|
|
294
|
+
err = res.error
|
|
295
|
+
message = None
|
|
296
|
+
if err is not None:
|
|
297
|
+
if isinstance(err, api.QuotaExceededError):
|
|
298
|
+
try:
|
|
299
|
+
from euron_code.usage_view import local_time
|
|
300
|
+
me = client.me(use_cache=False)
|
|
301
|
+
message = (f"You have used today's Euron Code token allowance. It resets at "
|
|
302
|
+
f"{local_time((me.get('quota') or {}).get('resetsAt'))}.")
|
|
303
|
+
except api.EuronError:
|
|
304
|
+
message = err.message
|
|
305
|
+
err.message = message
|
|
306
|
+
code = _report(ui, err) # message on stderr in both formats
|
|
307
|
+
message = err.message
|
|
308
|
+
elif res.stop_reason == "max_turns":
|
|
309
|
+
code = EXIT_MAX_TURNS
|
|
310
|
+
message = f"Stopped after --max-turns {args.max_turns}."
|
|
311
|
+
if args.output_format == "text":
|
|
312
|
+
ui.warn(message)
|
|
313
|
+
elif res.stop_reason == "interrupted":
|
|
314
|
+
code = EXIT_INT
|
|
315
|
+
t = agent.tokens
|
|
316
|
+
if args.output_format == "json":
|
|
317
|
+
out = {
|
|
318
|
+
"type": "result",
|
|
319
|
+
"subtype": "success" if code == 0 else ("error_max_turns" if code == EXIT_MAX_TURNS else "error"),
|
|
320
|
+
"is_error": code != 0,
|
|
321
|
+
"result": res.text,
|
|
322
|
+
"stop_reason": res.stop_reason,
|
|
323
|
+
"num_turns": res.turns,
|
|
324
|
+
"session_id": session.id,
|
|
325
|
+
"model": agent.model,
|
|
326
|
+
"usage": {"prompt_tokens": t.session_prompt, "completion_tokens": t.session_completion,
|
|
327
|
+
"total_tokens": t.session_total, "requests": t.requests},
|
|
328
|
+
"exit_code": code,
|
|
329
|
+
}
|
|
330
|
+
if err is not None:
|
|
331
|
+
out["error"] = {"code": err.code, "status": err.status, "message": message}
|
|
332
|
+
sys.stdout.write(json.dumps(out, ensure_ascii=False) + "\n")
|
|
333
|
+
else:
|
|
334
|
+
if res.text:
|
|
335
|
+
sys.stdout.write(res.text.rstrip() + "\n")
|
|
336
|
+
sys.stdout.flush()
|
|
337
|
+
return code
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _code_for(e: api.EuronError) -> int:
|
|
341
|
+
if isinstance(e, api.NotEntitledError):
|
|
342
|
+
return EXIT_NO_PLUS
|
|
343
|
+
if isinstance(e, api.AuthError):
|
|
344
|
+
return EXIT_AUTH
|
|
345
|
+
if isinstance(e, api.QuotaExceededError):
|
|
346
|
+
return EXIT_QUOTA
|
|
347
|
+
return EXIT_ERROR
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
if __name__ == "__main__":
|
|
351
|
+
raise SystemExit(main())
|
euron_code/config.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Paths, settings files and the `euron config` command.
|
|
2
|
+
|
|
3
|
+
Settings are merged in this order (later wins):
|
|
4
|
+
built-in defaults < ~/.euron/settings.json < <project>/.euron/settings.json
|
|
5
|
+
< <project>/.euron/settings.local.json
|
|
6
|
+
Permission allow/deny lists are concatenated rather than replaced.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
DEFAULT_BASE_URL = "https://api.euron.one/api/v1/code-agent"
|
|
19
|
+
|
|
20
|
+
#: Context window and output cap of the default model (Qwen3.6-27B, see BENCHMARK_RESULTS.md).
|
|
21
|
+
DEFAULT_CONTEXT_WINDOW = 32768
|
|
22
|
+
DEFAULT_MAX_OUTPUT = 8192
|
|
23
|
+
|
|
24
|
+
DEFAULTS: Dict[str, Any] = {
|
|
25
|
+
"model": None, # None -> use defaultModel from /v1/me
|
|
26
|
+
"reasoning": "collapse", # show | collapse | hide
|
|
27
|
+
"contextWindow": DEFAULT_CONTEXT_WINDOW,
|
|
28
|
+
"maxOutputTokens": DEFAULT_MAX_OUTPUT,
|
|
29
|
+
"autoCompact": True,
|
|
30
|
+
"compactThreshold": 0.75,
|
|
31
|
+
"toolOutputMaxChars": 12000,
|
|
32
|
+
"bashTimeout": 120,
|
|
33
|
+
"shell": None, # None -> auto (PowerShell/cmd on Windows, bash/sh elsewhere)
|
|
34
|
+
"webFetch": True,
|
|
35
|
+
"credentialStore": "auto", # auto | keyring | file
|
|
36
|
+
"permissions": {"defaultMode": "default", "allow": [], "deny": []},
|
|
37
|
+
"db": {"connections": {}, "allowWrites": False, "maxRows": 200},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def euron_home() -> Path:
|
|
42
|
+
"""~/.euron, overridable with EURON_HOME (used by tests)."""
|
|
43
|
+
env = os.environ.get("EURON_HOME")
|
|
44
|
+
p = Path(env).expanduser() if env else Path.home() / ".euron"
|
|
45
|
+
return p
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def ensure_home() -> Path:
|
|
49
|
+
home = euron_home()
|
|
50
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
return home
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def base_url() -> str:
|
|
55
|
+
url = os.environ.get("EURON_CODE_API") or load_user_settings().get("apiBaseUrl") or DEFAULT_BASE_URL
|
|
56
|
+
return url.rstrip("/")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def user_settings_path() -> Path:
|
|
60
|
+
return euron_home() / "settings.json"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def project_settings_path(root: Path) -> Path:
|
|
64
|
+
return root / ".euron" / "settings.json"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def project_local_settings_path(root: Path) -> Path:
|
|
68
|
+
return root / ".euron" / "settings.local.json"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _read_json(path: Path) -> Dict[str, Any]:
|
|
72
|
+
try:
|
|
73
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
74
|
+
data = json.load(f)
|
|
75
|
+
return data if isinstance(data, dict) else {}
|
|
76
|
+
except FileNotFoundError:
|
|
77
|
+
return {}
|
|
78
|
+
except (OSError, ValueError):
|
|
79
|
+
return {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def write_json(path: Path, data: Dict[str, Any]) -> None:
|
|
83
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
85
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
86
|
+
json.dump(data, f, indent=2, sort_keys=True)
|
|
87
|
+
f.write("\n")
|
|
88
|
+
os.replace(tmp, path)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_user_settings() -> Dict[str, Any]:
|
|
92
|
+
return _read_json(user_settings_path())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _merge(base: Dict[str, Any], over: Dict[str, Any]) -> Dict[str, Any]:
|
|
96
|
+
out = copy.deepcopy(base)
|
|
97
|
+
for k, v in over.items():
|
|
98
|
+
if k == "permissions" and isinstance(v, dict):
|
|
99
|
+
cur = out.setdefault("permissions", {"allow": [], "deny": []})
|
|
100
|
+
for lk in ("allow", "deny"):
|
|
101
|
+
if isinstance(v.get(lk), list):
|
|
102
|
+
cur[lk] = list(cur.get(lk, [])) + [str(x) for x in v[lk]]
|
|
103
|
+
if v.get("defaultMode"):
|
|
104
|
+
cur["defaultMode"] = v["defaultMode"]
|
|
105
|
+
elif isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
106
|
+
out[k] = _merge(out[k], v)
|
|
107
|
+
else:
|
|
108
|
+
out[k] = copy.deepcopy(v)
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_settings(root: Optional[Path] = None) -> Dict[str, Any]:
|
|
113
|
+
s = _merge(DEFAULTS, load_user_settings())
|
|
114
|
+
if root is not None:
|
|
115
|
+
s = _merge(s, _read_json(project_settings_path(root)))
|
|
116
|
+
s = _merge(s, _read_json(project_local_settings_path(root)))
|
|
117
|
+
return s
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def find_project_root(start: Optional[Path] = None) -> Path:
|
|
121
|
+
"""The project root is the current directory.
|
|
122
|
+
|
|
123
|
+
Claude Code treats the launch directory as the workspace; we do the same so that a
|
|
124
|
+
student who opens a sub-folder in VS Code gets exactly that folder.
|
|
125
|
+
"""
|
|
126
|
+
return (start or Path.cwd()).resolve()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def find_git_root(start: Path) -> Optional[Path]:
|
|
130
|
+
cur = start.resolve()
|
|
131
|
+
for p in [cur, *cur.parents]:
|
|
132
|
+
if (p / ".git").exists():
|
|
133
|
+
return p
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def git_branch(root: Path) -> Optional[str]:
|
|
138
|
+
try:
|
|
139
|
+
out = subprocess.run(
|
|
140
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
141
|
+
cwd=str(root), capture_output=True, text=True, timeout=3,
|
|
142
|
+
)
|
|
143
|
+
if out.returncode == 0:
|
|
144
|
+
return out.stdout.strip() or None
|
|
145
|
+
except (OSError, subprocess.SubprocessError):
|
|
146
|
+
pass
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------- euron config get/set
|
|
151
|
+
|
|
152
|
+
def _parse_value(raw: str) -> Any:
|
|
153
|
+
try:
|
|
154
|
+
return json.loads(raw)
|
|
155
|
+
except ValueError:
|
|
156
|
+
return raw
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_path(data: Dict[str, Any], dotted: str) -> Any:
|
|
160
|
+
cur: Any = data
|
|
161
|
+
for part in dotted.split("."):
|
|
162
|
+
if not isinstance(cur, dict) or part not in cur:
|
|
163
|
+
return None
|
|
164
|
+
cur = cur[part]
|
|
165
|
+
return cur
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def set_path(data: Dict[str, Any], dotted: str, value: Any) -> None:
|
|
169
|
+
parts = dotted.split(".")
|
|
170
|
+
cur = data
|
|
171
|
+
for part in parts[:-1]:
|
|
172
|
+
nxt = cur.get(part)
|
|
173
|
+
if not isinstance(nxt, dict):
|
|
174
|
+
nxt = {}
|
|
175
|
+
cur[part] = nxt
|
|
176
|
+
cur = nxt
|
|
177
|
+
if value is None:
|
|
178
|
+
cur.pop(parts[-1], None)
|
|
179
|
+
else:
|
|
180
|
+
cur[parts[-1]] = value
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def config_set(key: str, raw_value: str, project: Optional[Path] = None) -> Path:
|
|
184
|
+
path = project_settings_path(project) if project else user_settings_path()
|
|
185
|
+
data = _read_json(path)
|
|
186
|
+
set_path(data, key, _parse_value(raw_value))
|
|
187
|
+
write_json(path, data)
|
|
188
|
+
return path
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def config_unset(key: str, project: Optional[Path] = None) -> Path:
|
|
192
|
+
path = project_settings_path(project) if project else user_settings_path()
|
|
193
|
+
data = _read_json(path)
|
|
194
|
+
set_path(data, key, None)
|
|
195
|
+
write_json(path, data)
|
|
196
|
+
return path
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def add_permission_rule(root: Path, kind: str, rule: str, local: bool = True) -> Path:
|
|
200
|
+
path = project_local_settings_path(root) if local else project_settings_path(root)
|
|
201
|
+
data = _read_json(path)
|
|
202
|
+
perms = data.setdefault("permissions", {})
|
|
203
|
+
lst: List[str] = perms.setdefault(kind, [])
|
|
204
|
+
if rule not in lst:
|
|
205
|
+
lst.append(rule)
|
|
206
|
+
write_json(path, data)
|
|
207
|
+
return path
|
euron_code/context.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Context-window bookkeeping and compaction helpers.
|
|
2
|
+
|
|
3
|
+
The default model has a 32k window and up to 8k output. Prompt size comes from the API's
|
|
4
|
+
`usage.prompt_tokens` when available; between calls we add a cheap estimate (~3.5 chars per token)
|
|
5
|
+
for messages appended since, calibrated against the last real count.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
CHARS_PER_TOKEN = 3.5
|
|
14
|
+
MSG_OVERHEAD = 6
|
|
15
|
+
ELIDED = "[old tool output removed to save context; re-run the tool if you need it again]"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _msg_chars(m: Dict[str, Any]) -> int:
|
|
19
|
+
n = len(str(m.get("content") or ""))
|
|
20
|
+
for tc in m.get("tool_calls") or []:
|
|
21
|
+
fn = tc.get("function") or {}
|
|
22
|
+
n += len(fn.get("name") or "") + len(fn.get("arguments") or "")
|
|
23
|
+
return n
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def estimate_tokens(messages: List[Dict[str, Any]], tools: Optional[List[Dict[str, Any]]] = None) -> int:
|
|
27
|
+
chars = sum(_msg_chars(m) for m in messages)
|
|
28
|
+
if tools:
|
|
29
|
+
chars += len(json.dumps(tools))
|
|
30
|
+
return int(chars / CHARS_PER_TOKEN) + MSG_OVERHEAD * len(messages)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TokenTracker:
|
|
34
|
+
"""Tracks prompt size and session totals."""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self.ratio = 1.0 # real / estimated, from the last response
|
|
38
|
+
self.last_prompt_tokens: Optional[int] = None
|
|
39
|
+
self.session_prompt = 0
|
|
40
|
+
self.session_completion = 0
|
|
41
|
+
self.session_cached = 0
|
|
42
|
+
self.requests = 0
|
|
43
|
+
|
|
44
|
+
def record(self, usage: Optional[Dict[str, Any]], estimated_prompt: int) -> None:
|
|
45
|
+
self.requests += 1
|
|
46
|
+
if not usage:
|
|
47
|
+
return
|
|
48
|
+
pt = int(usage.get("prompt_tokens") or 0)
|
|
49
|
+
ct = int(usage.get("completion_tokens") or 0)
|
|
50
|
+
self.session_prompt += pt
|
|
51
|
+
self.session_completion += ct
|
|
52
|
+
details = usage.get("prompt_tokens_details") or {}
|
|
53
|
+
if isinstance(details, dict):
|
|
54
|
+
self.session_cached += int(details.get("cached_tokens") or 0)
|
|
55
|
+
if pt > 0 and estimated_prompt > 0:
|
|
56
|
+
self.ratio = max(0.5, min(2.0, pt / estimated_prompt))
|
|
57
|
+
self.last_prompt_tokens = pt
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def session_total(self) -> int:
|
|
61
|
+
return self.session_prompt + self.session_completion
|
|
62
|
+
|
|
63
|
+
def prompt_estimate(self, messages, tools) -> int:
|
|
64
|
+
return int(estimate_tokens(messages, tools) * self.ratio)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_boundary(m: Dict[str, Any]) -> bool:
|
|
68
|
+
# cutting before a user or assistant message never separates tool results from their call
|
|
69
|
+
return m.get("role") in ("user", "assistant")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def split_for_compaction(messages: List[Dict[str, Any]], keep_tokens: int) -> Tuple[int, int]:
|
|
73
|
+
"""Given messages (index 0 = system), return (start, cut): messages[1:cut] are summarised,
|
|
74
|
+
messages[cut:] are kept. `cut` lands on a user or assistant message, so an assistant tool call
|
|
75
|
+
always stays together with its results, and the kept tail is about `keep_tokens` long.
|
|
76
|
+
Returns cut == 1 when nothing can be split off."""
|
|
77
|
+
n = len(messages)
|
|
78
|
+
kept = 0
|
|
79
|
+
candidate = None
|
|
80
|
+
i = n - 1
|
|
81
|
+
while i >= 2:
|
|
82
|
+
kept += estimate_tokens([messages[i]])
|
|
83
|
+
if _is_boundary(messages[i]):
|
|
84
|
+
candidate = i
|
|
85
|
+
if kept >= keep_tokens:
|
|
86
|
+
break
|
|
87
|
+
i -= 1
|
|
88
|
+
if candidate is None:
|
|
89
|
+
return 1, 1
|
|
90
|
+
# keep at least the last exchange: never summarise the final message
|
|
91
|
+
if candidate >= n:
|
|
92
|
+
return 1, 1
|
|
93
|
+
return 1, candidate
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def elide_old_tool_outputs(messages: List[Dict[str, Any]], keep_last: int = 4, min_chars: int = 600) -> int:
|
|
97
|
+
"""Stage-1 compaction: replace large old tool results with a stub. Returns chars saved."""
|
|
98
|
+
tool_idx = [i for i, m in enumerate(messages) if m.get("role") == "tool"]
|
|
99
|
+
saved = 0
|
|
100
|
+
for i in tool_idx[:-keep_last] if keep_last else tool_idx:
|
|
101
|
+
c = str(messages[i].get("content") or "")
|
|
102
|
+
if len(c) > min_chars and c != ELIDED:
|
|
103
|
+
first = c.splitlines()[0][:160] if c else ""
|
|
104
|
+
stub = f"{first}\n{ELIDED}"
|
|
105
|
+
saved += len(c) - len(stub)
|
|
106
|
+
messages[i] = dict(messages[i], content=stub)
|
|
107
|
+
return saved
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def render_transcript(messages: List[Dict[str, Any]], per_message_chars: int = 1500) -> str:
|
|
111
|
+
out = []
|
|
112
|
+
for m in messages:
|
|
113
|
+
role = m.get("role")
|
|
114
|
+
content = str(m.get("content") or "")
|
|
115
|
+
if len(content) > per_message_chars:
|
|
116
|
+
content = content[: per_message_chars // 2] + "\n[...]\n" + content[-per_message_chars // 2:]
|
|
117
|
+
if role == "assistant":
|
|
118
|
+
calls = []
|
|
119
|
+
for tc in m.get("tool_calls") or []:
|
|
120
|
+
fn = tc.get("function") or {}
|
|
121
|
+
args = fn.get("arguments") or ""
|
|
122
|
+
calls.append(f"{fn.get('name')}({args[:400]})")
|
|
123
|
+
line = "ASSISTANT: " + content
|
|
124
|
+
if calls:
|
|
125
|
+
line += "\n tool calls: " + "; ".join(calls)
|
|
126
|
+
out.append(line)
|
|
127
|
+
elif role == "tool":
|
|
128
|
+
out.append(f"TOOL RESULT ({m.get('name', '')}): {content}")
|
|
129
|
+
elif role == "user":
|
|
130
|
+
out.append("USER: " + content)
|
|
131
|
+
return "\n\n".join(out)
|