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