@litfamily/lithermes 1.0.12 → 1.0.14
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.
- package/README.md +17 -33
- package/README_Ko-KR.md +16 -32
- package/assets/lithermes-plugin/README.md +29 -0
- package/assets/lithermes-plugin/__init__.py +24 -2
- package/assets/lithermes-plugin/auto_handoff.py +679 -0
- package/assets/lithermes-plugin/core.py +10 -0
- package/assets/lithermes-plugin/diagnostics.py +8 -0
- package/assets/lithermes-plugin/handoff.py +13 -2
- package/assets/lithermes-plugin/payload-version.json +15 -11
- package/assets/lithermes-plugin/plugin.yaml +1 -1
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -2
- package/assets/lithermes-plugin/skills/lit-handoff/SKILL.md +7 -0
- package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +2 -2
- package/package.json +1 -1
- package/readme-assets/badge-version.svg +1 -1
- package/readme-assets/ab-simple/s11-ui-baseline-desktop.webp +0 -0
- package/readme-assets/ab-simple/s11-ui-lithermes-desktop.webp +0 -0
- package/readme-assets/ab-simple/s3-ui-baseline-desktop.webp +0 -0
- package/readme-assets/ab-simple/s3-ui-lithermes-desktop.webp +0 -0
- package/readme-assets/ab-simple/s4-ui-baseline-desktop.webp +0 -0
- package/readme-assets/ab-simple/s4-ui-lithermes-desktop.webp +0 -0
- package/readme-assets/ab-simple/s5-office-baseline-slides.webp +0 -0
- package/readme-assets/ab-simple/s5-office-lithermes-slides.webp +0 -0
- package/readme-assets/ab-simple/s7-lithermes-diagram.webp +0 -0
- package/readme-assets/ab-simple/s8-office-baseline-slides.webp +0 -0
- package/readme-assets/ab-simple/s8-office-lithermes-slides.webp +0 -0
- package/readme-assets/ab-simple/s9-office-lithermes-pages.webp +0 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
"""Opt-in automatic handoff for Hermes sessions.
|
|
2
|
+
|
|
3
|
+
Off by default. The user turns it on with ``/lit-handoff auto on <percent>`` or
|
|
4
|
+
with ``LITHERMES_AUTO_HANDOFF=1`` plus ``LITHERMES_AUTO_HANDOFF_PERCENT``. The
|
|
5
|
+
percent is always the user's own: there is no built-in value, and a value that
|
|
6
|
+
is not a whole number from 1 to 99 leaves the feature off.
|
|
7
|
+
|
|
8
|
+
How it works on this host, step by step:
|
|
9
|
+
|
|
10
|
+
1. ``post_api_request`` reports the prompt size of every model call. The plugin
|
|
11
|
+
divides it by the model's context window and keeps the latest reading per
|
|
12
|
+
session. Crossing the user's percent marks one pending directive.
|
|
13
|
+
2. ``pre_llm_call`` is the only hook whose return value reaches the model, so
|
|
14
|
+
the directive rides on the next user turn. It asks the model to write the
|
|
15
|
+
handoff with the bundled lit-handoff procedure, to put a one-time id line in
|
|
16
|
+
the file, and to tell the user to run the compact command.
|
|
17
|
+
3. A plugin cannot start compaction on Hermes. The user runs it, or Hermes
|
|
18
|
+
compacts on its own threshold. The next ``pre_llm_call`` notices the new
|
|
19
|
+
compaction summary in the history and loads a bounded digest of the handoff,
|
|
20
|
+
but only if the file carries this session's id and was written after the
|
|
21
|
+
directive. Anything else is refused.
|
|
22
|
+
|
|
23
|
+
The feature fires once per crossing of the percent, never inside a tool call,
|
|
24
|
+
and never for delegate children. State is kept in memory per session; only the
|
|
25
|
+
user's switch and last percent persist, in Hermes home ``lithermes/``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import contextlib
|
|
31
|
+
import hashlib
|
|
32
|
+
import html
|
|
33
|
+
import importlib
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import secrets
|
|
38
|
+
import stat
|
|
39
|
+
import threading
|
|
40
|
+
import time
|
|
41
|
+
from collections.abc import Mapping
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any, Callable
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
from .core_runtime import assert_within_isolation, get_hermes_home, utc_now
|
|
48
|
+
from .provider_metrics import _nonnegative_int, _value
|
|
49
|
+
from .redaction import redact_text
|
|
50
|
+
from .rules import constants as _RULE_CONSTANTS
|
|
51
|
+
from .session_context import is_delegate_child_platform
|
|
52
|
+
except (ImportError, ModuleNotFoundError):
|
|
53
|
+
from core_runtime import assert_within_isolation, get_hermes_home, utc_now # type: ignore
|
|
54
|
+
from provider_metrics import _nonnegative_int, _value # type: ignore
|
|
55
|
+
from redaction import redact_text # type: ignore
|
|
56
|
+
from rules import constants as _RULE_CONSTANTS # type: ignore
|
|
57
|
+
from session_context import is_delegate_child_platform # type: ignore
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
FLAG = "LITHERMES_AUTO_HANDOFF"
|
|
61
|
+
PERCENT_ENV = "LITHERMES_AUTO_HANDOFF_PERCENT"
|
|
62
|
+
SESSION_ENV = "HERMES_SESSION_ID"
|
|
63
|
+
SETTINGS_FILE = "auto-handoff.json"
|
|
64
|
+
ROUTE = "/lit-handoff auto"
|
|
65
|
+
SOURCE_ROOT = Path(__file__).resolve().parent / "vendor" / "handoff"
|
|
66
|
+
SOURCE_SKILL = SOURCE_ROOT / "SKILL.md"
|
|
67
|
+
SOURCE_TEMPLATE = SOURCE_ROOT / "templates" / "HANDOFF.md"
|
|
68
|
+
MAX_TRACKED_SESSIONS = 256
|
|
69
|
+
MAX_SETTINGS_BYTES = 4096
|
|
70
|
+
MAX_HANDOFF_READ_BYTES = 64 * 1024
|
|
71
|
+
MAX_HANDOFF_FILE_BYTES = 2 * 1024 * 1024
|
|
72
|
+
MAX_DIGEST_BYTES = 1400
|
|
73
|
+
MAX_DIGEST_LINES = 10
|
|
74
|
+
MAX_DIGEST_LINE_CHARS = 200
|
|
75
|
+
HOST_LOOKUP_SECONDS = 3.0
|
|
76
|
+
FAILED_LOOKUP_RETRY_SECONDS = 60.0
|
|
77
|
+
CLOCK_SLACK_SECONDS = 2.0
|
|
78
|
+
HANDOFF_CANDIDATES = ("HANDOFF.md", ".handoff/HANDOFF.md")
|
|
79
|
+
PERCENT_RULE = "a whole number from 1 to 99"
|
|
80
|
+
_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
|
|
81
|
+
_PERCENT = re.compile(r"[0-9]{1,2}")
|
|
82
|
+
_BARE_HANDOFF = re.compile(r"^\s*handoff\s*$", re.IGNORECASE)
|
|
83
|
+
_HEADING = re.compile(r"^#{1,4}\s*(.+?)\s*$")
|
|
84
|
+
_WANTED_SECTIONS = (("current state", "Current State"), ("next steps", "Next Steps"))
|
|
85
|
+
_LOCK = threading.RLock()
|
|
86
|
+
_SESSIONS: dict[str, dict[str, Any]] = {}
|
|
87
|
+
_WINDOWS: dict[tuple[str, str, str], tuple[int | None, float]] = {}
|
|
88
|
+
_LAST_SESSION: list[str] = [""]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class Setting:
|
|
93
|
+
on: bool
|
|
94
|
+
percent: int | None
|
|
95
|
+
flag_source: str
|
|
96
|
+
percent_source: str
|
|
97
|
+
warnings: tuple[str, ...]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _reset_for_tests() -> None:
|
|
101
|
+
with _LOCK:
|
|
102
|
+
_SESSIONS.clear()
|
|
103
|
+
_WINDOWS.clear()
|
|
104
|
+
_LAST_SESSION[0] = ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_percent(text: Any) -> int | None:
|
|
108
|
+
"""Return the percent for a whole number from 1 to 99, else None."""
|
|
109
|
+
value = str(text or "").strip()
|
|
110
|
+
if value.endswith("%"):
|
|
111
|
+
value = value[:-1]
|
|
112
|
+
if not _PERCENT.fullmatch(value):
|
|
113
|
+
return None
|
|
114
|
+
number = int(value)
|
|
115
|
+
return number if 1 <= number <= 99 else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---- the user's switch -----------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def settings_path() -> Path:
|
|
121
|
+
return get_hermes_home() / "lithermes" / SETTINGS_FILE
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _read_saved() -> tuple[dict[str, Any], str]:
|
|
125
|
+
"""Return the saved switch and, when the file is damaged, one warning."""
|
|
126
|
+
blank: dict[str, Any] = {"enabled": False, "percent": None, "exists": False}
|
|
127
|
+
try:
|
|
128
|
+
fd = os.open(settings_path(), os.O_RDONLY | _NOFOLLOW)
|
|
129
|
+
except OSError:
|
|
130
|
+
return blank, ""
|
|
131
|
+
try:
|
|
132
|
+
with os.fdopen(fd, "rb") as handle:
|
|
133
|
+
raw = handle.read(MAX_SETTINGS_BYTES + 1)
|
|
134
|
+
data = json.loads(raw[:MAX_SETTINGS_BYTES].decode("utf-8"))
|
|
135
|
+
except (OSError, ValueError, UnicodeDecodeError):
|
|
136
|
+
return blank, "the saved setting file could not be read, so automatic handoff stays off"
|
|
137
|
+
if not isinstance(data, dict):
|
|
138
|
+
return blank, "the saved setting file could not be read, so automatic handoff stays off"
|
|
139
|
+
enabled = data.get("enabled") is True
|
|
140
|
+
percent = data.get("percent")
|
|
141
|
+
if percent is None:
|
|
142
|
+
return {"enabled": enabled, "percent": None, "exists": True}, ""
|
|
143
|
+
if isinstance(percent, bool) or not isinstance(percent, int) or not 1 <= percent <= 99:
|
|
144
|
+
return {"enabled": enabled, "percent": None, "exists": True}, (
|
|
145
|
+
f"the saved percent is not {PERCENT_RULE}, so automatic handoff stays off"
|
|
146
|
+
)
|
|
147
|
+
return {"enabled": enabled, "percent": percent, "exists": True}, ""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _write_saved(enabled: bool, percent: int | None) -> None:
|
|
151
|
+
path = settings_path()
|
|
152
|
+
assert_within_isolation(path)
|
|
153
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
|
|
155
|
+
payload = {"enabled": enabled, "percent": percent, "updated": utc_now().isoformat()}
|
|
156
|
+
try:
|
|
157
|
+
with contextlib.suppress(OSError):
|
|
158
|
+
temporary.unlink()
|
|
159
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | _NOFOLLOW, 0o600)
|
|
160
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
161
|
+
json.dump(payload, handle, sort_keys=True)
|
|
162
|
+
# os.replace swaps a symlink planted at the final path instead of writing through it.
|
|
163
|
+
os.replace(temporary, path)
|
|
164
|
+
except OSError:
|
|
165
|
+
with contextlib.suppress(OSError):
|
|
166
|
+
temporary.unlink()
|
|
167
|
+
raise
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def resolve(env: Mapping[str, str] | None = None) -> Setting:
|
|
171
|
+
"""Combine the environment and the saved switch into the effective setting.
|
|
172
|
+
|
|
173
|
+
The environment wins when it names a value, so a shell profile can pin the
|
|
174
|
+
feature; the saved value fills in otherwise. No percent is ever invented.
|
|
175
|
+
"""
|
|
176
|
+
values = os.environ if env is None else env
|
|
177
|
+
saved, saved_warning = _read_saved()
|
|
178
|
+
warnings: list[str] = []
|
|
179
|
+
|
|
180
|
+
raw_flag = (values.get(FLAG) or "").strip()
|
|
181
|
+
if raw_flag:
|
|
182
|
+
enabled, flag_source = raw_flag == "1", "environment"
|
|
183
|
+
else:
|
|
184
|
+
enabled, flag_source = bool(saved["enabled"]), ("saved" if saved["exists"] else "default")
|
|
185
|
+
|
|
186
|
+
raw_percent = (values.get(PERCENT_ENV) or "").strip()
|
|
187
|
+
if raw_percent:
|
|
188
|
+
percent, percent_source = parse_percent(raw_percent), "environment"
|
|
189
|
+
if percent is None:
|
|
190
|
+
warnings.append(f"{PERCENT_ENV} is {raw_percent[:20]!r}, which is not {PERCENT_RULE}, so automatic handoff stays off")
|
|
191
|
+
else:
|
|
192
|
+
percent = saved["percent"]
|
|
193
|
+
percent_source = "saved" if percent is not None else "none"
|
|
194
|
+
if saved_warning:
|
|
195
|
+
warnings.append(saved_warning)
|
|
196
|
+
|
|
197
|
+
if enabled and percent is None and not warnings:
|
|
198
|
+
warnings.append(f"it is switched on but no percent is chosen; run {ROUTE} on <percent>")
|
|
199
|
+
return Setting(enabled and percent is not None, percent, flag_source, percent_source, tuple(warnings))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def parse_route(raw_args: str) -> str | None:
|
|
203
|
+
"""Return the text after ``auto`` when the route's first word is exactly ``auto``."""
|
|
204
|
+
parts = str(raw_args or "").strip().split(None, 1)
|
|
205
|
+
if not parts or parts[0].lower() != "auto":
|
|
206
|
+
return None
|
|
207
|
+
return parts[1].strip() if len(parts) > 1 else ""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _shown(value: Any) -> str:
|
|
211
|
+
return redact_text(str(value))[:20]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def run_command(rest: str, env: Mapping[str, str] | None = None, session_id: str = "") -> str:
|
|
215
|
+
"""Handle ``/lit-handoff auto on <percent> | on | off | status`` as plain text."""
|
|
216
|
+
values = os.environ if env is None else env
|
|
217
|
+
tokens = str(rest or "").split()
|
|
218
|
+
sub = tokens[0].lower() if tokens else "status"
|
|
219
|
+
if sub == "status" and len(tokens) <= 1:
|
|
220
|
+
return _status_text(values, session_id)
|
|
221
|
+
if sub == "off" and len(tokens) == 1:
|
|
222
|
+
return _command_off(values)
|
|
223
|
+
if sub == "on" and len(tokens) <= 2:
|
|
224
|
+
return _command_on(values, tokens[1] if len(tokens) == 2 else "")
|
|
225
|
+
return f"Usage: {ROUTE} on <percent> | off | status"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _command_off(values: Mapping[str, str]) -> str:
|
|
229
|
+
saved, _ = _read_saved()
|
|
230
|
+
try:
|
|
231
|
+
_write_saved(False, saved["percent"])
|
|
232
|
+
except (OSError, RuntimeError) as exc:
|
|
233
|
+
return f"Could not save the setting ({type(exc).__name__}); nothing changed."
|
|
234
|
+
lines = ["Automatic handoff is off."]
|
|
235
|
+
if saved["percent"] is not None:
|
|
236
|
+
lines.append(f"Your last percent, {saved['percent']}%, is remembered for the next time you turn it on.")
|
|
237
|
+
if (values.get(FLAG) or "").strip() == "1":
|
|
238
|
+
lines.append(f"{FLAG}=1 is set in your environment, so it stays on until you unset that variable.")
|
|
239
|
+
return " ".join(lines)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _command_on(values: Mapping[str, str], argument: str) -> str:
|
|
243
|
+
saved, _ = _read_saved()
|
|
244
|
+
if argument:
|
|
245
|
+
percent = parse_percent(argument)
|
|
246
|
+
if percent is None:
|
|
247
|
+
return f"{_shown(argument)!r} is not {PERCENT_RULE}. Nothing changed."
|
|
248
|
+
elif saved["percent"] is not None:
|
|
249
|
+
percent = saved["percent"]
|
|
250
|
+
else:
|
|
251
|
+
return (
|
|
252
|
+
f"Which percent should trigger it? Run {ROUTE} on <percent> with {PERCENT_RULE}. "
|
|
253
|
+
"LitHermes has no built-in value and will not pick one for you."
|
|
254
|
+
)
|
|
255
|
+
try:
|
|
256
|
+
_write_saved(True, percent)
|
|
257
|
+
except (OSError, RuntimeError) as exc:
|
|
258
|
+
return f"Could not save the setting ({type(exc).__name__}); nothing changed."
|
|
259
|
+
lines = [
|
|
260
|
+
f"Automatic handoff is ON at {percent}%. Once a model call passes {percent}% of the context window, "
|
|
261
|
+
f"your next message carries a request to write a handoff, and you then run {_compact_command()}. "
|
|
262
|
+
"It starts with your next message."
|
|
263
|
+
]
|
|
264
|
+
flag = (values.get(FLAG) or "").strip()
|
|
265
|
+
if flag and flag != "1":
|
|
266
|
+
lines.append(f"{FLAG}={_shown(flag)} is set in your environment, which keeps it off until you unset that variable.")
|
|
267
|
+
environment_percent = (values.get(PERCENT_ENV) or "").strip()
|
|
268
|
+
if environment_percent:
|
|
269
|
+
lines.append(f"{PERCENT_ENV}={_shown(environment_percent)} is set in your environment and wins over the saved {percent}%; unset it to use {percent}%.")
|
|
270
|
+
lines.append(_host_comparison(resolve(values))[0])
|
|
271
|
+
return "\n".join(lines)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _status_text(values: Mapping[str, str], session_id: str) -> str:
|
|
275
|
+
setting = resolve(values)
|
|
276
|
+
if setting.on:
|
|
277
|
+
head = f"Automatic handoff: ON at {setting.percent}% (percent from your {setting.percent_source} setting)"
|
|
278
|
+
elif setting.warnings:
|
|
279
|
+
head = f"Automatic handoff: off ({'; '.join(setting.warnings)})"
|
|
280
|
+
else:
|
|
281
|
+
head = "Automatic handoff: off"
|
|
282
|
+
lines = [head]
|
|
283
|
+
if setting.on:
|
|
284
|
+
lines.append(_reading_line(session_id or (values.get(SESSION_ENV) or "").strip() or _LAST_SESSION[0]))
|
|
285
|
+
lines.append(_host_comparison(setting)[0])
|
|
286
|
+
return "\n".join(lines)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _reading_line(session_id: str) -> str:
|
|
290
|
+
with _LOCK:
|
|
291
|
+
state = _SESSIONS.get(session_id)
|
|
292
|
+
reading = dict(state) if state else {}
|
|
293
|
+
if not reading or reading.get("used") is None:
|
|
294
|
+
return "Last reading in this session: no reading yet"
|
|
295
|
+
if reading.get("percent") is None:
|
|
296
|
+
return "Last reading in this session: unavailable (the model's context window is unknown)"
|
|
297
|
+
return (
|
|
298
|
+
f"Last reading in this session: {reading['percent']:.0f}% "
|
|
299
|
+
f"({reading['used']:,} of {reading['window']:,} tokens)"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def status_line(env: Mapping[str, str] | None = None) -> str:
|
|
304
|
+
setting = resolve(env)
|
|
305
|
+
if setting.on:
|
|
306
|
+
return f"Automatic handoff: on at {setting.percent}%"
|
|
307
|
+
if setting.warnings:
|
|
308
|
+
return f"Automatic handoff: off ({'; '.join(setting.warnings)})"
|
|
309
|
+
return "Automatic handoff: off"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def doctor_line(env: Mapping[str, str] | None = None) -> tuple[str, str]:
|
|
313
|
+
"""Return (tag, text) for doctor: NOTE when off, WARN when it cannot work as chosen."""
|
|
314
|
+
setting = resolve(env)
|
|
315
|
+
if setting.warnings and not setting.on:
|
|
316
|
+
return "WARN", f"Automatic handoff: off ({'; '.join(setting.warnings)})"
|
|
317
|
+
if not setting.on:
|
|
318
|
+
return "NOTE", "Automatic handoff: off"
|
|
319
|
+
point, reason = host_estimate()
|
|
320
|
+
if point is None:
|
|
321
|
+
return "OK", f"Automatic handoff: on at {setting.percent}% ({reason}; no comparison with Hermes' own compaction point)"
|
|
322
|
+
if setting.percent >= round(point):
|
|
323
|
+
return "WARN", (
|
|
324
|
+
f"Automatic handoff: on at {setting.percent}%, but Hermes compacts at about {point:.0f}% of the window, "
|
|
325
|
+
"so Hermes compacts first; choose a lower percent"
|
|
326
|
+
)
|
|
327
|
+
return "OK", f"Automatic handoff: on at {setting.percent}% (Hermes compacts at about {point:.0f}% of the window)"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _host_comparison(setting: Setting) -> tuple[str, bool]:
|
|
331
|
+
"""Say where Hermes' own compaction sits next to the user's percent; True when it wins."""
|
|
332
|
+
point, reason = host_estimate()
|
|
333
|
+
if point is None:
|
|
334
|
+
return f"Hermes' own compaction point: unavailable ({reason})", False
|
|
335
|
+
if setting.percent >= round(point):
|
|
336
|
+
return (
|
|
337
|
+
f"Heads up: Hermes compacts at about {point:.0f}% of the window, so it compacts first and "
|
|
338
|
+
"no handoff is written. Choose a lower percent.",
|
|
339
|
+
True,
|
|
340
|
+
)
|
|
341
|
+
return f"Hermes' own compaction point is about {point:.0f}% of the window, above yours.", False
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ---- the host's own compaction point ---------------------------------------
|
|
345
|
+
|
|
346
|
+
def _host_config() -> dict[str, Any]:
|
|
347
|
+
try:
|
|
348
|
+
path = get_hermes_home() / "config.yaml"
|
|
349
|
+
if path.stat().st_size > 1024 * 1024:
|
|
350
|
+
return {}
|
|
351
|
+
yaml = importlib.import_module("yaml")
|
|
352
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
353
|
+
except Exception: # noqa: BLE001 - a config we cannot read only removes the comparison
|
|
354
|
+
return {}
|
|
355
|
+
return data if isinstance(data, dict) else {}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _host_facts() -> dict[str, Any]:
|
|
359
|
+
"""Read the running host's own compaction defaults; empty outside Hermes."""
|
|
360
|
+
facts: dict[str, Any] = {}
|
|
361
|
+
with contextlib.suppress(Exception):
|
|
362
|
+
module = importlib.import_module("agent.context_compressor")
|
|
363
|
+
facts["floor"] = (int(module._SMALL_CTX_WINDOW_LIMIT), float(module._SMALL_CTX_THRESHOLD_PERCENT))
|
|
364
|
+
with contextlib.suppress(Exception):
|
|
365
|
+
defaults = importlib.import_module("hermes_cli.config_defaults").DEFAULT_CONFIG["compression"]
|
|
366
|
+
if isinstance(defaults, dict):
|
|
367
|
+
facts["compression"] = dict(defaults)
|
|
368
|
+
return facts
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _ratio(value: Any) -> float | None:
|
|
372
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
373
|
+
return None
|
|
374
|
+
return float(value) if 0 < value <= 1 else None
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def host_trigger_percent(
|
|
378
|
+
window: int,
|
|
379
|
+
compression: Mapping[str, Any],
|
|
380
|
+
model: str,
|
|
381
|
+
floor: tuple[int, float] | None = None,
|
|
382
|
+
) -> float | None:
|
|
383
|
+
"""Estimate where Hermes' own compaction starts, as a percent of the window.
|
|
384
|
+
|
|
385
|
+
Follows the documented compression settings: the ratio (with a per-model
|
|
386
|
+
override and the small-window floor when the host has one) and the absolute
|
|
387
|
+
token cap. Returns None when compression is switched off.
|
|
388
|
+
"""
|
|
389
|
+
if compression.get("enabled") is False:
|
|
390
|
+
return None
|
|
391
|
+
ratio = _ratio(compression.get("threshold")) or 0.50
|
|
392
|
+
overrides = compression.get("model_thresholds")
|
|
393
|
+
if isinstance(overrides, Mapping) and model:
|
|
394
|
+
keys = [key for key in overrides if isinstance(key, str) and key and key.lower() in model.lower()]
|
|
395
|
+
if keys:
|
|
396
|
+
best = max(keys, key=len)
|
|
397
|
+
ratio = _ratio(overrides[best]) or ratio
|
|
398
|
+
if floor is not None and window < floor[0]:
|
|
399
|
+
ratio = max(ratio, floor[1])
|
|
400
|
+
trigger = int(window * ratio)
|
|
401
|
+
cap = compression.get("threshold_tokens")
|
|
402
|
+
if isinstance(cap, int) and not isinstance(cap, bool) and cap > 0:
|
|
403
|
+
trigger = min(trigger, cap, window)
|
|
404
|
+
return trigger * 100 / window
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _bounded(function: Callable[[], Any], seconds: float = HOST_LOOKUP_SECONDS) -> Any:
|
|
408
|
+
"""Run a host lookup that may touch the network, giving up after a few seconds."""
|
|
409
|
+
box: list[Any] = [None]
|
|
410
|
+
|
|
411
|
+
def work() -> None:
|
|
412
|
+
with contextlib.suppress(Exception):
|
|
413
|
+
box[0] = function()
|
|
414
|
+
|
|
415
|
+
thread = threading.Thread(target=work, daemon=True)
|
|
416
|
+
thread.start()
|
|
417
|
+
thread.join(seconds)
|
|
418
|
+
return box[0]
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _host_context_length(model: str, base_url: str, provider: str) -> int | None:
|
|
422
|
+
"""Ask the running Hermes for the model's context window; None outside a host."""
|
|
423
|
+
function = importlib.import_module("agent.model_metadata").get_model_context_length
|
|
424
|
+
value = function(model, base_url or "", provider=provider or "")
|
|
425
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _context_window(model: str, base_url: str, provider: str, config: Mapping[str, Any] | None = None) -> int | None:
|
|
429
|
+
key = (model, base_url, provider)
|
|
430
|
+
now = time.monotonic()
|
|
431
|
+
with _LOCK:
|
|
432
|
+
cached = _WINDOWS.get(key)
|
|
433
|
+
if cached and (cached[0] is not None or cached[1] > now):
|
|
434
|
+
return cached[0]
|
|
435
|
+
section = (config if config is not None else _host_config()).get("model")
|
|
436
|
+
section = section if isinstance(section, Mapping) else {}
|
|
437
|
+
configured = section.get("context_length")
|
|
438
|
+
window: int | None = None
|
|
439
|
+
if isinstance(configured, int) and not isinstance(configured, bool) and configured > 0:
|
|
440
|
+
if not section.get("default") or section.get("default") == model:
|
|
441
|
+
window = configured
|
|
442
|
+
if window is None and model:
|
|
443
|
+
found = _bounded(lambda: _host_context_length(model, base_url, provider))
|
|
444
|
+
window = found if isinstance(found, int) and found > 0 else None
|
|
445
|
+
with _LOCK:
|
|
446
|
+
while len(_WINDOWS) >= MAX_TRACKED_SESSIONS:
|
|
447
|
+
_WINDOWS.pop(next(iter(_WINDOWS)), None)
|
|
448
|
+
_WINDOWS[key] = (window, now + FAILED_LOOKUP_RETRY_SECONDS)
|
|
449
|
+
return window
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def host_estimate() -> tuple[float | None, str]:
|
|
453
|
+
"""Estimate Hermes' compaction point for the configured model, or say why not."""
|
|
454
|
+
config = _host_config()
|
|
455
|
+
section = config.get("model") if isinstance(config.get("model"), Mapping) else {}
|
|
456
|
+
model = str(section.get("default") or "")
|
|
457
|
+
window = _context_window(model, str(section.get("base_url") or ""), str(section.get("provider") or ""), config)
|
|
458
|
+
if window is None:
|
|
459
|
+
return None, "the model's context window is unknown (window unknown)"
|
|
460
|
+
facts = _host_facts()
|
|
461
|
+
compression = dict(facts.get("compression") or {})
|
|
462
|
+
user = config.get("compression")
|
|
463
|
+
if isinstance(user, Mapping):
|
|
464
|
+
compression.update(user)
|
|
465
|
+
point = host_trigger_percent(window, compression, model, facts.get("floor"))
|
|
466
|
+
if point is None:
|
|
467
|
+
return None, "Hermes compression is switched off"
|
|
468
|
+
return point, ""
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _compact_command() -> str:
|
|
472
|
+
"""The slash command that compacts here; only Hermes 0.19 and later know /compact."""
|
|
473
|
+
try:
|
|
474
|
+
resolve_command = importlib.import_module("hermes_cli.commands").resolve_command
|
|
475
|
+
return "/compact" if resolve_command("compact") else "/compress"
|
|
476
|
+
except Exception: # noqa: BLE001 - outside a host the plain wording is the right default
|
|
477
|
+
return "/compact"
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# ---- reading the model's context use ----------------------------------------
|
|
481
|
+
|
|
482
|
+
def _prompt_tokens(usage: Any) -> int | None:
|
|
483
|
+
if usage is None:
|
|
484
|
+
return None
|
|
485
|
+
prompt = _nonnegative_int(_value(usage, "prompt_tokens"))
|
|
486
|
+
if prompt is not None:
|
|
487
|
+
return prompt
|
|
488
|
+
parts = [_nonnegative_int(_value(usage, name)) for name in ("input_tokens", "cache_read_tokens", "cache_write_tokens")]
|
|
489
|
+
return sum(parts) if all(part is not None for part in parts) else None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _state(session_id: str) -> dict[str, Any]:
|
|
493
|
+
state = _SESSIONS.get(session_id)
|
|
494
|
+
if state is None:
|
|
495
|
+
while len(_SESSIONS) >= MAX_TRACKED_SESSIONS:
|
|
496
|
+
_SESSIONS.pop(next(iter(_SESSIONS)), None)
|
|
497
|
+
state = _SESSIONS[session_id] = {
|
|
498
|
+
"percent": None, "used": None, "window": None,
|
|
499
|
+
"above": False, "pending": False, "awaiting": False, "dropped": False,
|
|
500
|
+
"nonce": "", "fired_at": 0.0, "cwd": "", "summary": "",
|
|
501
|
+
}
|
|
502
|
+
return state
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def begin_session(session_id: Any) -> None:
|
|
506
|
+
with _LOCK:
|
|
507
|
+
_SESSIONS.pop(str(session_id or ""), None)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def release_session(session_id: Any) -> None:
|
|
511
|
+
begin_session(session_id)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def post_api_request(**kwargs: Any) -> None:
|
|
515
|
+
"""Keep the latest context reading and mark a crossing of the user's percent."""
|
|
516
|
+
setting = resolve()
|
|
517
|
+
if not setting.on:
|
|
518
|
+
return None
|
|
519
|
+
session_id = str(kwargs.get("session_id") or "")
|
|
520
|
+
if not session_id or is_delegate_child_platform(str(kwargs.get("platform") or "")):
|
|
521
|
+
return None
|
|
522
|
+
used = _prompt_tokens(kwargs.get("usage"))
|
|
523
|
+
if used is None:
|
|
524
|
+
return None
|
|
525
|
+
window = _context_window(str(kwargs.get("model") or ""), str(kwargs.get("base_url") or ""), str(kwargs.get("provider") or ""))
|
|
526
|
+
with _LOCK:
|
|
527
|
+
state = _state(session_id)
|
|
528
|
+
_LAST_SESSION[0] = session_id
|
|
529
|
+
state["used"], state["window"] = used, window
|
|
530
|
+
if window is None:
|
|
531
|
+
state["percent"] = None
|
|
532
|
+
return None
|
|
533
|
+
percent = used * 100 / window
|
|
534
|
+
state["percent"] = percent
|
|
535
|
+
if percent >= setting.percent:
|
|
536
|
+
if not state["above"]:
|
|
537
|
+
state["above"] = True
|
|
538
|
+
if not state["awaiting"]:
|
|
539
|
+
state["pending"] = True
|
|
540
|
+
else:
|
|
541
|
+
state["above"] = False
|
|
542
|
+
state["pending"] = False
|
|
543
|
+
if state["awaiting"]:
|
|
544
|
+
state["dropped"] = True
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
# ---- the directive and the reload -------------------------------------------
|
|
549
|
+
|
|
550
|
+
def _summary_fingerprint(history: Any) -> str:
|
|
551
|
+
if not isinstance(history, (list, tuple)):
|
|
552
|
+
return ""
|
|
553
|
+
parts: list[str] = []
|
|
554
|
+
for message in history:
|
|
555
|
+
if not isinstance(message, dict):
|
|
556
|
+
continue
|
|
557
|
+
content = str(message.get("content") or "")
|
|
558
|
+
lowered = content.lower()
|
|
559
|
+
if message.get(_RULE_CONSTANTS.COMPACTION_METADATA_KEY) or any(
|
|
560
|
+
marker in lowered for marker in _RULE_CONSTANTS.COMPACTION_TEXT_MARKERS
|
|
561
|
+
):
|
|
562
|
+
parts.append(content[:4000])
|
|
563
|
+
return hashlib.sha256("\x00".join(parts).encode("utf-8", "replace")).hexdigest()[:16] if parts else ""
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _explicit_handoff_turn(message: str) -> bool:
|
|
567
|
+
return bool(_BARE_HANDOFF.fullmatch(message)) or "<lithermes-handoff-route" in message
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def pre_llm_call(**kwargs: Any) -> str:
|
|
571
|
+
"""Return this turn's directive or reload block, or an empty string."""
|
|
572
|
+
setting = resolve()
|
|
573
|
+
if not setting.on:
|
|
574
|
+
return ""
|
|
575
|
+
session_id = str(kwargs.get("session_id") or "")
|
|
576
|
+
if not session_id or is_delegate_child_platform(str(kwargs.get("platform") or "")):
|
|
577
|
+
return ""
|
|
578
|
+
history = kwargs.get("conversation_history")
|
|
579
|
+
with _LOCK:
|
|
580
|
+
state = _SESSIONS.get(session_id)
|
|
581
|
+
if state is None:
|
|
582
|
+
return ""
|
|
583
|
+
_LAST_SESSION[0] = session_id
|
|
584
|
+
if state["awaiting"]:
|
|
585
|
+
fingerprint = _summary_fingerprint(history)
|
|
586
|
+
if fingerprint and fingerprint != state["summary"]:
|
|
587
|
+
state["awaiting"], state["dropped"] = False, False
|
|
588
|
+
return _reload_block(state)
|
|
589
|
+
if state["dropped"]:
|
|
590
|
+
state["awaiting"], state["dropped"] = False, False
|
|
591
|
+
return ""
|
|
592
|
+
if not state["pending"] or _explicit_handoff_turn(str(kwargs.get("user_message") or "")):
|
|
593
|
+
return ""
|
|
594
|
+
state["pending"], state["awaiting"], state["dropped"] = False, True, False
|
|
595
|
+
state["nonce"] = secrets.token_hex(6)
|
|
596
|
+
state["fired_at"] = time.time()
|
|
597
|
+
state["cwd"] = os.getcwd()
|
|
598
|
+
state["summary"] = _summary_fingerprint(history)
|
|
599
|
+
return _directive_block(state, setting.percent)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _directive_block(state: Mapping[str, Any], percent: int) -> str:
|
|
603
|
+
return "\n".join([
|
|
604
|
+
f'<lithermes-auto-handoff id="{state["nonce"]}" used="{state["percent"]:.0f}%" threshold="{percent}%">',
|
|
605
|
+
f"This conversation has used {state['percent']:.0f}% of the model's context window, past the {percent}% "
|
|
606
|
+
"the user chose for an automatic handoff. Write the handoff now, before anything else:",
|
|
607
|
+
f"1. Read {SOURCE_SKILL} and follow that lit-handoff procedure. The template is {SOURCE_TEMPLATE}.",
|
|
608
|
+
f"2. Save the handoff where the procedure says, and put this exact line on its own line near the top: auto-handoff-id: {state['nonce']}",
|
|
609
|
+
f"3. Read the file back, then tell the user exactly this one line: Handoff saved. Run {_compact_command()} now.",
|
|
610
|
+
"After that, carry on with the user's request. The user set this up in their own settings.",
|
|
611
|
+
"</lithermes-auto-handoff>",
|
|
612
|
+
])
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _find_handoff(state: Mapping[str, Any]) -> tuple[str, float, str] | None:
|
|
616
|
+
found: tuple[str, float, str] | None = None
|
|
617
|
+
for relative in HANDOFF_CANDIDATES:
|
|
618
|
+
path = Path(state["cwd"]) / relative
|
|
619
|
+
try:
|
|
620
|
+
info = os.lstat(path)
|
|
621
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_HANDOFF_FILE_BYTES:
|
|
622
|
+
continue
|
|
623
|
+
if info.st_mtime < state["fired_at"] - CLOCK_SLACK_SECONDS:
|
|
624
|
+
continue
|
|
625
|
+
fd = os.open(path, os.O_RDONLY | _NOFOLLOW)
|
|
626
|
+
with os.fdopen(fd, "rb") as handle:
|
|
627
|
+
text = handle.read(MAX_HANDOFF_READ_BYTES).decode("utf-8", "replace")
|
|
628
|
+
except OSError:
|
|
629
|
+
continue
|
|
630
|
+
if re.search(rf"auto-handoff-id:[ \t]*{re.escape(state['nonce'])}(?![0-9a-f])", text) is None:
|
|
631
|
+
continue
|
|
632
|
+
if found is None or info.st_mtime > found[1]:
|
|
633
|
+
found = (relative, info.st_mtime, text)
|
|
634
|
+
return found
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _digest(text: str, nonce: str) -> str:
|
|
638
|
+
sections: dict[str, list[str]] = {}
|
|
639
|
+
current = ""
|
|
640
|
+
body: list[str] = []
|
|
641
|
+
for line in text.splitlines():
|
|
642
|
+
heading = _HEADING.match(line)
|
|
643
|
+
if heading:
|
|
644
|
+
title = heading.group(1).lower()
|
|
645
|
+
current = next((label for key, label in _WANTED_SECTIONS if key in title), "")
|
|
646
|
+
continue
|
|
647
|
+
if nonce in line or not line.strip():
|
|
648
|
+
continue
|
|
649
|
+
if len(body) < MAX_DIGEST_LINES:
|
|
650
|
+
body.append(line.strip()[:MAX_DIGEST_LINE_CHARS])
|
|
651
|
+
if current and len(sections.setdefault(current, [])) < MAX_DIGEST_LINES:
|
|
652
|
+
sections[current].append(line.strip()[:MAX_DIGEST_LINE_CHARS])
|
|
653
|
+
parts = [f"{label}:\n" + "\n".join(sections[label]) for _, label in _WANTED_SECTIONS if sections.get(label)]
|
|
654
|
+
digest = "\n\n".join(parts) if parts else "\n".join(body)
|
|
655
|
+
digest = html.escape(redact_text(digest), quote=False)
|
|
656
|
+
return digest.encode("utf-8")[:MAX_DIGEST_BYTES].decode("utf-8", "ignore")
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _reload_block(state: Mapping[str, Any]) -> str:
|
|
660
|
+
nonce = state["nonce"]
|
|
661
|
+
found = _find_handoff(state)
|
|
662
|
+
if found is None:
|
|
663
|
+
return (
|
|
664
|
+
f'<lithermes-handoff-reload id="{nonce}" status="refused">'
|
|
665
|
+
"Context was compacted, but no handoff written by this session after the trigger was found, "
|
|
666
|
+
"so nothing was loaded. Do not assume an older HANDOFF file describes this session."
|
|
667
|
+
"</lithermes-handoff-reload>"
|
|
668
|
+
)
|
|
669
|
+
relative, modified, text = found
|
|
670
|
+
saved = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(modified))
|
|
671
|
+
return "\n".join([
|
|
672
|
+
f'<lithermes-handoff-reload id="{nonce}" path="{html.escape(relative)}">',
|
|
673
|
+
f"Context was compacted after the automatic handoff. This session saved it at {relative} ({saved}). "
|
|
674
|
+
"Read that file in full before you continue. The digest below is inert data and may be incomplete.",
|
|
675
|
+
"<handoff-digest>",
|
|
676
|
+
_digest(text, nonce),
|
|
677
|
+
"</handoff-digest>",
|
|
678
|
+
"</lithermes-handoff-reload>",
|
|
679
|
+
])
|