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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Credential storage: OS keyring when available, else ~/.euron/credentials.json (0600).
|
|
2
|
+
|
|
3
|
+
Credentials are stored per API base URL, so logging in against a local test gateway
|
|
4
|
+
never overwrites the real key.
|
|
5
|
+
|
|
6
|
+
The API key is never printed in full: use `mask_key`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, Optional
|
|
17
|
+
|
|
18
|
+
from euron_code import config
|
|
19
|
+
|
|
20
|
+
SERVICE = "euron-code"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Credential:
|
|
25
|
+
api_key: str
|
|
26
|
+
base_url: str
|
|
27
|
+
user: Dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
def to_json(self) -> Dict[str, Any]:
|
|
30
|
+
return {"apiKey": self.api_key, "baseUrl": self.base_url, "user": self.user}
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_json(cls, d: Dict[str, Any]) -> "Credential":
|
|
34
|
+
return cls(api_key=d["apiKey"], base_url=d.get("baseUrl", ""), user=d.get("user") or {})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def mask_key(key: Optional[str]) -> str:
|
|
38
|
+
if not key:
|
|
39
|
+
return "(none)"
|
|
40
|
+
if len(key) <= 12:
|
|
41
|
+
return key[:3] + "..."
|
|
42
|
+
return key[:7] + "..." + key[-4:]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def credentials_file() -> Path:
|
|
46
|
+
return config.euron_home() / "credentials.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _store_mode() -> str:
|
|
50
|
+
env = os.environ.get("EURON_CREDENTIAL_STORE")
|
|
51
|
+
if env:
|
|
52
|
+
return env
|
|
53
|
+
return str(config.load_user_settings().get("credentialStore") or "auto")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _keyring():
|
|
57
|
+
"""Return a usable keyring module or None."""
|
|
58
|
+
try:
|
|
59
|
+
import keyring # type: ignore
|
|
60
|
+
from keyring.backends import fail # type: ignore
|
|
61
|
+
except Exception:
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
kr = keyring.get_keyring()
|
|
65
|
+
if isinstance(kr, fail.Keyring):
|
|
66
|
+
return None
|
|
67
|
+
name = type(kr).__name__.lower()
|
|
68
|
+
if "null" in name or "fail" in name:
|
|
69
|
+
return None
|
|
70
|
+
# chainer with no usable backends also counts as unusable
|
|
71
|
+
if getattr(kr, "backends", None) == []:
|
|
72
|
+
return None
|
|
73
|
+
except Exception:
|
|
74
|
+
return None
|
|
75
|
+
return keyring
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def keyring_backend_name() -> str:
|
|
79
|
+
kr = _keyring()
|
|
80
|
+
if kr is None:
|
|
81
|
+
return "unavailable (using file)"
|
|
82
|
+
try:
|
|
83
|
+
k = kr.get_keyring()
|
|
84
|
+
return f"{type(k).__module__}.{type(k).__name__}"
|
|
85
|
+
except Exception:
|
|
86
|
+
return "unknown"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------- file store
|
|
90
|
+
|
|
91
|
+
def _read_file() -> Dict[str, Any]:
|
|
92
|
+
try:
|
|
93
|
+
with open(credentials_file(), "r", encoding="utf-8") as f:
|
|
94
|
+
data = json.load(f)
|
|
95
|
+
return data if isinstance(data, dict) else {}
|
|
96
|
+
except (OSError, ValueError):
|
|
97
|
+
return {}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _write_file(data: Dict[str, Any]) -> None:
|
|
101
|
+
path = credentials_file()
|
|
102
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
tmp = path.with_suffix(".tmp")
|
|
104
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
105
|
+
fd = os.open(str(tmp), flags, 0o600)
|
|
106
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
107
|
+
json.dump(data, f, indent=2)
|
|
108
|
+
try:
|
|
109
|
+
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
|
|
110
|
+
except OSError:
|
|
111
|
+
pass
|
|
112
|
+
os.replace(tmp, path)
|
|
113
|
+
try:
|
|
114
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------- public API
|
|
120
|
+
|
|
121
|
+
def save(cred: Credential) -> str:
|
|
122
|
+
"""Save and return the store used ('keyring' or 'file')."""
|
|
123
|
+
mode = _store_mode()
|
|
124
|
+
if mode in ("auto", "keyring"):
|
|
125
|
+
kr = _keyring()
|
|
126
|
+
if kr is not None:
|
|
127
|
+
try:
|
|
128
|
+
kr.set_password(SERVICE, cred.base_url, json.dumps(cred.to_json()))
|
|
129
|
+
# remove any stale file copy for this URL
|
|
130
|
+
data = _read_file()
|
|
131
|
+
if cred.base_url in data:
|
|
132
|
+
data.pop(cred.base_url)
|
|
133
|
+
_write_file(data)
|
|
134
|
+
return "keyring"
|
|
135
|
+
except Exception:
|
|
136
|
+
if mode == "keyring":
|
|
137
|
+
raise
|
|
138
|
+
data = _read_file()
|
|
139
|
+
data[cred.base_url] = cred.to_json()
|
|
140
|
+
_write_file(data)
|
|
141
|
+
return "file"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def load(base_url: Optional[str] = None) -> Optional[Credential]:
|
|
145
|
+
url = base_url or config.base_url()
|
|
146
|
+
env_key = os.environ.get("EURON_API_KEY")
|
|
147
|
+
if env_key:
|
|
148
|
+
return Credential(api_key=env_key, base_url=url, user={})
|
|
149
|
+
mode = _store_mode()
|
|
150
|
+
if mode in ("auto", "keyring"):
|
|
151
|
+
kr = _keyring()
|
|
152
|
+
if kr is not None:
|
|
153
|
+
try:
|
|
154
|
+
raw = kr.get_password(SERVICE, url)
|
|
155
|
+
if raw:
|
|
156
|
+
return Credential.from_json(json.loads(raw))
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
d = _read_file().get(url)
|
|
160
|
+
if isinstance(d, dict) and d.get("apiKey"):
|
|
161
|
+
return Credential.from_json(d)
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def where(base_url: Optional[str] = None) -> str:
|
|
166
|
+
url = base_url or config.base_url()
|
|
167
|
+
if os.environ.get("EURON_API_KEY"):
|
|
168
|
+
return "env EURON_API_KEY"
|
|
169
|
+
kr = _keyring() if _store_mode() in ("auto", "keyring") else None
|
|
170
|
+
if kr is not None:
|
|
171
|
+
try:
|
|
172
|
+
if kr.get_password(SERVICE, url):
|
|
173
|
+
return "keyring"
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
if url in _read_file():
|
|
177
|
+
return f"file {credentials_file()}"
|
|
178
|
+
return "none"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def delete(base_url: Optional[str] = None) -> bool:
|
|
182
|
+
url = base_url or config.base_url()
|
|
183
|
+
removed = False
|
|
184
|
+
kr = _keyring()
|
|
185
|
+
if kr is not None:
|
|
186
|
+
try:
|
|
187
|
+
if kr.get_password(SERVICE, url):
|
|
188
|
+
kr.delete_password(SERVICE, url)
|
|
189
|
+
removed = True
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
data = _read_file()
|
|
193
|
+
if url in data:
|
|
194
|
+
data.pop(url)
|
|
195
|
+
_write_file(data)
|
|
196
|
+
removed = True
|
|
197
|
+
return removed
|
euron_code/doctor.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""`euron doctor`: check the local setup and the connection to Euron Code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import sys
|
|
8
|
+
from typing import List, Tuple
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from rich.markup import escape
|
|
12
|
+
|
|
13
|
+
from euron_code import __version__, api, config, credentials
|
|
14
|
+
from euron_code.tools.search import ripgrep_path
|
|
15
|
+
from euron_code.tools.shell import detect_shell
|
|
16
|
+
|
|
17
|
+
OK, WARN, FAIL = "ok", "warn", "fail"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_checks() -> List[Tuple[str, str, str]]:
|
|
21
|
+
checks: List[Tuple[str, str, str]] = []
|
|
22
|
+
v = sys.version_info
|
|
23
|
+
checks.append(("Python", OK if v >= (3, 9) else FAIL,
|
|
24
|
+
f"{platform.python_version()} ({sys.executable})"))
|
|
25
|
+
checks.append(("euron-code", OK, __version__))
|
|
26
|
+
checks.append(("Platform", OK, f"{platform.system()} {platform.release()} {platform.machine()}"))
|
|
27
|
+
url = config.base_url()
|
|
28
|
+
src = "EURON_CODE_API" if os.environ.get("EURON_CODE_API") else "default"
|
|
29
|
+
checks.append(("API base URL", OK, f"{url} ({src})"))
|
|
30
|
+
|
|
31
|
+
# connectivity: any HTTP answer means the gateway is reachable
|
|
32
|
+
try:
|
|
33
|
+
r = httpx.get(url + "/v1/models", timeout=10.0, headers={"User-Agent": api.user_agent()})
|
|
34
|
+
checks.append(("Connectivity", OK, f"reachable (HTTP {r.status_code})"))
|
|
35
|
+
reachable = True
|
|
36
|
+
except httpx.HTTPError as e:
|
|
37
|
+
checks.append(("Connectivity", FAIL, f"cannot reach {url}: {type(e).__name__}"))
|
|
38
|
+
reachable = False
|
|
39
|
+
|
|
40
|
+
cred = credentials.load(url)
|
|
41
|
+
if cred is None:
|
|
42
|
+
checks.append(("Login", WARN, "not logged in; run `euron login`"))
|
|
43
|
+
else:
|
|
44
|
+
checks.append(("Login", OK, f"key {credentials.mask_key(cred.api_key)} in {credentials.where(url)}"))
|
|
45
|
+
if reachable:
|
|
46
|
+
c = api.Client(api_key=cred.api_key, base_url=url)
|
|
47
|
+
try:
|
|
48
|
+
me = c.me(use_cache=False)
|
|
49
|
+
access = me.get("access") or {}
|
|
50
|
+
if access.get("entitled"):
|
|
51
|
+
q = me.get("quota") or {}
|
|
52
|
+
checks.append(("Entitlement", OK, f"{api.plan_label(access)}; "
|
|
53
|
+
f"{q.get('remainingToday', '?'):,} tokens left today"
|
|
54
|
+
if isinstance(q.get("remainingToday"), int) else api.plan_label(access)))
|
|
55
|
+
else:
|
|
56
|
+
checks.append(("Entitlement", FAIL, api.NO_PLUS_MESSAGE))
|
|
57
|
+
except api.AuthError:
|
|
58
|
+
checks.append(("Entitlement", FAIL, "the saved key was rejected (401); run `euron login`"))
|
|
59
|
+
except api.EuronError as e:
|
|
60
|
+
checks.append(("Entitlement", WARN, f"could not check: {e.message}"))
|
|
61
|
+
finally:
|
|
62
|
+
c.close()
|
|
63
|
+
|
|
64
|
+
rg = ripgrep_path()
|
|
65
|
+
checks.append(("ripgrep", OK if rg else WARN, rg or "not found; grep uses the slower Python fallback"))
|
|
66
|
+
checks.append(("Keyring", OK if "unavailable" not in credentials.keyring_backend_name() else WARN,
|
|
67
|
+
credentials.keyring_backend_name()))
|
|
68
|
+
kind, exe = detect_shell(config.load_user_settings().get("shell"))
|
|
69
|
+
checks.append(("Shell", OK, f"{kind} ({exe})"))
|
|
70
|
+
term = os.environ.get("TERM_PROGRAM") or os.environ.get("TERM") or ("Windows console" if os.name == "nt" else "?")
|
|
71
|
+
checks.append(("Terminal", OK if sys.stdout.isatty() else WARN,
|
|
72
|
+
f"{term}{'' if sys.stdout.isatty() else ' (output is not a terminal)'}"))
|
|
73
|
+
checks.append(("Config", OK, f"{config.user_settings_path()}"))
|
|
74
|
+
return checks
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def doctor(ui) -> int:
|
|
78
|
+
checks = run_checks()
|
|
79
|
+
style = {OK: "[green]OK [/green]", WARN: "[yellow]WARN[/yellow]", FAIL: "[red]FAIL[/red]"}
|
|
80
|
+
for name, st, detail in checks:
|
|
81
|
+
ui.info(f"{style[st]} {name:<14} {escape(detail)}")
|
|
82
|
+
fails = [c for c in checks if c[1] == FAIL]
|
|
83
|
+
ui.info("")
|
|
84
|
+
if fails:
|
|
85
|
+
ui.info(f"[red]{len(fails)} problem(s) found.[/red]")
|
|
86
|
+
return 1
|
|
87
|
+
ui.info("[green]All checks passed.[/green]")
|
|
88
|
+
return 0
|
euron_code/keys.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Esc-to-interrupt while the model is generating.
|
|
2
|
+
|
|
3
|
+
A background thread watches the keyboard (msvcrt on Windows, cbreak termios elsewhere) and sets
|
|
4
|
+
an Event when Esc is pressed. Ctrl-C works everywhere as well (KeyboardInterrupt).
|
|
5
|
+
It is only active during streaming, never while a prompt is reading input.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EscWatcher:
|
|
18
|
+
def __init__(self, event: threading.Event, enabled: bool = True):
|
|
19
|
+
self.event = event
|
|
20
|
+
self.enabled = enabled and _stdin_is_tty()
|
|
21
|
+
self._stop = threading.Event()
|
|
22
|
+
self._thread: Optional[threading.Thread] = None
|
|
23
|
+
self._old_term = None
|
|
24
|
+
|
|
25
|
+
def __enter__(self) -> "EscWatcher":
|
|
26
|
+
if not self.enabled:
|
|
27
|
+
return self
|
|
28
|
+
try:
|
|
29
|
+
if os.name != "nt":
|
|
30
|
+
import termios
|
|
31
|
+
import tty
|
|
32
|
+
fd = sys.stdin.fileno()
|
|
33
|
+
self._old_term = termios.tcgetattr(fd)
|
|
34
|
+
tty.setcbreak(fd)
|
|
35
|
+
except Exception:
|
|
36
|
+
self._old_term = None
|
|
37
|
+
self.enabled = False
|
|
38
|
+
return self
|
|
39
|
+
self._thread = threading.Thread(target=self._run, name="euron-esc", daemon=True)
|
|
40
|
+
self._thread.start()
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def _run(self) -> None:
|
|
44
|
+
if os.name == "nt":
|
|
45
|
+
import msvcrt
|
|
46
|
+
while not self._stop.is_set():
|
|
47
|
+
try:
|
|
48
|
+
if msvcrt.kbhit():
|
|
49
|
+
ch = msvcrt.getwch()
|
|
50
|
+
if ch == "\x1b":
|
|
51
|
+
self.event.set()
|
|
52
|
+
return
|
|
53
|
+
if ch in ("\x00", "\xe0"): # function/arrow key prefix: swallow
|
|
54
|
+
msvcrt.getwch()
|
|
55
|
+
except Exception:
|
|
56
|
+
return
|
|
57
|
+
time.sleep(0.05)
|
|
58
|
+
else:
|
|
59
|
+
import select
|
|
60
|
+
fd = sys.stdin.fileno()
|
|
61
|
+
while not self._stop.is_set():
|
|
62
|
+
try:
|
|
63
|
+
r, _, _ = select.select([fd], [], [], 0.05)
|
|
64
|
+
if r:
|
|
65
|
+
ch = os.read(fd, 1)
|
|
66
|
+
if ch == b"\x1b":
|
|
67
|
+
# a lone Esc, not the start of an arrow-key sequence
|
|
68
|
+
r2, _, _ = select.select([fd], [], [], 0.03)
|
|
69
|
+
if not r2:
|
|
70
|
+
self.event.set()
|
|
71
|
+
return
|
|
72
|
+
os.read(fd, 8)
|
|
73
|
+
except Exception:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
def __exit__(self, *exc) -> None:
|
|
77
|
+
self._stop.set()
|
|
78
|
+
if self._thread is not None:
|
|
79
|
+
self._thread.join(timeout=0.5)
|
|
80
|
+
if self._old_term is not None:
|
|
81
|
+
try:
|
|
82
|
+
import termios
|
|
83
|
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._old_term)
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _stdin_is_tty() -> bool:
|
|
89
|
+
try:
|
|
90
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
91
|
+
except (AttributeError, ValueError):
|
|
92
|
+
return False
|
euron_code/memory.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""EURON.md memory files.
|
|
2
|
+
|
|
3
|
+
Loaded (in this order) into the system prompt:
|
|
4
|
+
1. ~/.euron/EURON.md (user memory, all projects)
|
|
5
|
+
2. EURON.md in each parent directory from the git root down to the project root
|
|
6
|
+
3. <project>/EURON.md (project memory, usually committed)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
|
|
15
|
+
from euron_code import config
|
|
16
|
+
|
|
17
|
+
FILENAME = "EURON.md"
|
|
18
|
+
MAX_MEMORY_CHARS = 12000 # keep memory from eating the 32k window
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class MemoryFile:
|
|
23
|
+
path: Path
|
|
24
|
+
scope: str # user | parent | project
|
|
25
|
+
text: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def memory_paths(root: Path) -> List[tuple]:
|
|
29
|
+
root = root.resolve()
|
|
30
|
+
out = [(config.euron_home() / FILENAME, "user")]
|
|
31
|
+
git_root = config.find_git_root(root)
|
|
32
|
+
if git_root is not None and git_root != root:
|
|
33
|
+
chain = []
|
|
34
|
+
cur = root.parent
|
|
35
|
+
while True:
|
|
36
|
+
chain.append(cur)
|
|
37
|
+
if cur == git_root or cur == cur.parent:
|
|
38
|
+
break
|
|
39
|
+
cur = cur.parent
|
|
40
|
+
for d in reversed(chain):
|
|
41
|
+
out.append((d / FILENAME, "parent"))
|
|
42
|
+
out.append((root / FILENAME, "project"))
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load(root: Path) -> List[MemoryFile]:
|
|
47
|
+
files = []
|
|
48
|
+
for p, scope in memory_paths(root):
|
|
49
|
+
try:
|
|
50
|
+
text = p.read_text(encoding="utf-8", errors="replace").strip()
|
|
51
|
+
except OSError:
|
|
52
|
+
continue
|
|
53
|
+
if text:
|
|
54
|
+
files.append(MemoryFile(p, scope, text))
|
|
55
|
+
return files
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def render(files: List[MemoryFile]) -> str:
|
|
59
|
+
if not files:
|
|
60
|
+
return ""
|
|
61
|
+
parts = []
|
|
62
|
+
budget = MAX_MEMORY_CHARS
|
|
63
|
+
for f in files:
|
|
64
|
+
text = f.text
|
|
65
|
+
if len(text) > budget:
|
|
66
|
+
text = text[:budget] + "\n[... memory file truncated ...]"
|
|
67
|
+
budget -= len(text)
|
|
68
|
+
parts.append(f"<memory scope=\"{f.scope}\" path=\"{f.path.as_posix()}\">\n{text}\n</memory>")
|
|
69
|
+
if budget <= 0:
|
|
70
|
+
break
|
|
71
|
+
return "\n\n".join(parts)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def project_file(root: Path) -> Path:
|
|
75
|
+
return root.resolve() / FILENAME
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def user_file() -> Path:
|
|
79
|
+
return config.euron_home() / FILENAME
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def append_note(note: str, root: Path, scope: str = "project") -> Path:
|
|
83
|
+
path = user_file() if scope == "user" else project_file(root)
|
|
84
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
existing: Optional[str] = None
|
|
86
|
+
try:
|
|
87
|
+
existing = path.read_text(encoding="utf-8")
|
|
88
|
+
except OSError:
|
|
89
|
+
existing = None
|
|
90
|
+
note = note.strip()
|
|
91
|
+
with open(path, "a", encoding="utf-8", newline="") as f:
|
|
92
|
+
if existing is None or not existing.strip():
|
|
93
|
+
f.write("# Notes for Euron Code\n\n")
|
|
94
|
+
elif not existing.endswith("\n"):
|
|
95
|
+
f.write("\n")
|
|
96
|
+
f.write(f"- {note}\n")
|
|
97
|
+
return path
|