@litfamily/lithermes 1.0.13 → 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 +11 -27
- package/README_Ko-KR.md +11 -27
- 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
package/README.md
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
<p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
1
|
+
<p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitHermes robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
|
|
2
2
|
|
|
3
|
-
<p align="center"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
3
|
+
<p align="center"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/ascii-readme.svg" width="480" alt="LIT ASCII B mark" /></p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<a href="#install"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
7
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
6
|
+
<a href="#install"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/badge-version.svg" alt="1.0.14" /></a>
|
|
7
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/badge-license.svg" alt="MIT license" /></a>
|
|
8
8
|
</p>
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
11
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
12
|
-
<a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
13
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
11
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/lucide-book-open.svg" width="16" alt="" /> Guide</a>
|
|
12
|
+
<a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/lucide-play.svg" width="16" alt="" /> Ignition film</a>
|
|
13
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/lucide-shield-check.svg" width="16" alt="" /> MIT</a>
|
|
14
14
|
</p>
|
|
15
15
|
|
|
16
16
|
# LitHermes
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
A plugin for **Hermes Agent**. Add `lit` to a request, and Hermes plans the task, does it, checks it, and leaves a note the next session can pick up.
|
|
21
21
|
|
|
22
|
-
**[Full guide
|
|
22
|
+
**[Full guide and skills gallery on GitHub](https://github.com/wjgoarxiv/lithermes#readme)** · [한국어](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/README_Ko-KR.md)
|
|
23
23
|
|
|
24
24
|
## Install
|
|
25
25
|
|
|
@@ -84,25 +84,9 @@ On a Telegram gateway, use `/lit_loop` and `/lit_plan`. Every skill also loads b
|
|
|
84
84
|
- To stop every version check, set `NO_UPDATE_NOTIFIER=1` or `LITHERMES_NO_UPDATE_CHECK=1`. Any value counts for these variables. The GitHub page explains the update step in full.
|
|
85
85
|
- LitHermes decides where a request goes; Hermes Agent still runs the model.
|
|
86
86
|
|
|
87
|
-
##
|
|
87
|
+
## Automatic handoff
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
| Task | Prompt | Final verdict | Blind judge (same round) |
|
|
92
|
-
|---|---|---|---|
|
|
93
|
-
| S1 · Terminal to-do CLI | 터미널에서 쓰는 할 일 관리 CLI 만들어줘 | Tie | Baseline won |
|
|
94
|
-
| S2 · API server bugs | 이 API 서버 가끔 이상하게 동작하는데 고쳐줘 | Tie | Tie |
|
|
95
|
-
| S3 · Budget dashboard (UI round) | 개인 가계부 대시보드 웹페이지 만들어줘 | **LitHermes won** | LitHermes won |
|
|
96
|
-
| S4 · Café landing page (UI round) | 동네 카페 브랜드 랜딩페이지 만들어줘 | **LitHermes won** | LitHermes won |
|
|
97
|
-
| S5 · Report and slides from sources (office round) | sources 폴더 자료로 보고서랑 발표자료 만들어줘 | **LitHermes won** | LitHermes won |
|
|
98
|
-
| S6 · Node 22→24 research | Node 22에서 24로 올릴 때 달라지는 거 조사해줘 | **LitHermes won** | Tie |
|
|
99
|
-
| S7 · Order, payment and shipping diagram | 주문-결제-배송 서비스 구조도 그려줘 | **LitHermes won** | LitHermes won |
|
|
100
|
-
| S8 · Quarterly results deck (office round) | 분기 실적 발표자료 만들어줘 | **LitHermes won** | Baseline won |
|
|
101
|
-
| S9 · New product plan (office round) | 신제품 기획서 써줘 | **LitHermes won** | LitHermes won |
|
|
102
|
-
| S11 · Meeting-room booking web app (UI round) | 회의실 예약 웹앱 만들어줘 | **LitHermes won** | Tie |
|
|
103
|
-
| Total | | **8 won, 2 tied, 0 lost** | 5 won, 3 tied, 2 lost |
|
|
104
|
-
|
|
105
|
-
The judge preferred the baseline in S1 and S8. What each side produced, the screenshots, and the notes on which tasks came from later rounds are on GitHub. The motion cover at the top was made with the LitFamily motion skill. That skill (`lit-typographic-motion` here) was rebuilt after its first A/B and has no A/B result yet.
|
|
89
|
+
This is off by default, and the percent is yours to choose. `/lit-handoff auto on 60` (any whole number from 1 to 99) makes LitHermes ask the model for a handoff on the first message after a model call passes 60% of the context window, and then tell you to run `/compact`. A plugin cannot start compaction on Hermes, so you run it, or Hermes compacts on its own threshold. After compaction the handoff comes back as a short digest. `/lit-handoff auto off` and `/lit-handoff auto status` do what they say, and `LITHERMES_AUTO_HANDOFF=1` with `LITHERMES_AUTO_HANDOFF_PERCENT` sets it from the environment. The [GitHub README](https://github.com/wjgoarxiv/lithermes#automatic-handoff) lists which steps are automatic and how to pick a percent that Hermes' own compaction leaves alone.
|
|
106
90
|
|
|
107
91
|
## Optional: Jev skill hint
|
|
108
92
|
|
|
@@ -130,7 +114,7 @@ npx --package @litfamily/lithermes -- lithermes uninstall --yes
|
|
|
130
114
|
|
|
131
115
|
## Learn more
|
|
132
116
|
|
|
133
|
-
- [Full README on GitHub](https://github.com/wjgoarxiv/lithermes#readme): the skills gallery
|
|
117
|
+
- [Full README on GitHub](https://github.com/wjgoarxiv/lithermes#readme): the skills gallery and how it works
|
|
134
118
|
- [Operating guide](https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.md): commands, models, skins and troubleshooting
|
|
135
119
|
- [Changelog](https://github.com/wjgoarxiv/lithermes/blob/main/CHANGELOG.md) · [Contributing](https://github.com/wjgoarxiv/lithermes/blob/main/CONTRIBUTING.md) · [Security](https://github.com/wjgoarxiv/lithermes/blob/main/SECURITY.md) · [Support](https://github.com/wjgoarxiv/lithermes/blob/main/SUPPORT.md)
|
|
136
120
|
|
package/README_Ko-KR.md
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
<p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
1
|
+
<p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitHermes 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
|
|
2
2
|
|
|
3
|
-
<p align="center"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
3
|
+
<p align="center"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/ascii-readme.svg" width="480" alt="LIT ASCII B 마크" /></p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<a href="#설치"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
7
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
6
|
+
<a href="#설치"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/badge-version.svg" alt="1.0.14" /></a>
|
|
7
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/badge-license.svg" alt="MIT license" /></a>
|
|
8
8
|
</p>
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
11
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.ko.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
12
|
-
<a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
13
|
-
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.
|
|
11
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.ko.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/lucide-book-open.svg" width="16" alt="" /> 안내</a>
|
|
12
|
+
<a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/lucide-play.svg" width="16" alt="" /> Ignition 영상</a>
|
|
13
|
+
<a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/readme-assets/lucide-shield-check.svg" width="16" alt="" /> MIT</a>
|
|
14
14
|
</p>
|
|
15
15
|
|
|
16
16
|
# LitHermes
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
**Hermes Agent**용 플러그인입니다. 요청에 `lit`을 붙이면 Hermes가 작업을 계획하고, 실행하고, 확인한 뒤 다음 세션이 이어받을 기록을 남깁니다.
|
|
21
21
|
|
|
22
|
-
**[전체
|
|
22
|
+
**[전체 안내와 스킬 갤러리는 GitHub에서](https://github.com/wjgoarxiv/lithermes/blob/main/README_Ko-KR.md)** · [English](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.14/README.md)
|
|
23
23
|
|
|
24
24
|
## 설치
|
|
25
25
|
|
|
@@ -83,25 +83,9 @@ Telegram gateway에서는 `/lit_loop`와 `/lit_plan`을 쓰세요. 모든 스킬
|
|
|
83
83
|
- 버전 확인을 모두 멈추려면 `NO_UPDATE_NOTIFIER=1` 또는 `LITHERMES_NO_UPDATE_CHECK=1`을 설정하세요. 이 변수들은 어떤 값이든 똑같이 적용됩니다. 업데이트 과정의 자세한 설명은 GitHub 문서에 있습니다.
|
|
84
84
|
- 요청을 어디로 보낼지는 LitHermes가 정하고, 모델은 여전히 Hermes Agent가 돌립니다.
|
|
85
85
|
|
|
86
|
-
##
|
|
86
|
+
## 자동 핸드오프
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
| 작업 | 요청 | 최종 판정 | 블라인드 판정(같은 라운드) |
|
|
91
|
-
|---|---|---|---|
|
|
92
|
-
| S1 · 터미널 할 일 CLI | 터미널에서 쓰는 할 일 관리 CLI 만들어줘 | 무승부 | 기준선 승 |
|
|
93
|
-
| S2 · API 서버 버그 | 이 API 서버 가끔 이상하게 동작하는데 고쳐줘 | 무승부 | 무승부 |
|
|
94
|
-
| S3 · 가계부 대시보드 (UI 라운드) | 개인 가계부 대시보드 웹페이지 만들어줘 | **LitHermes 승** | LitHermes 승 |
|
|
95
|
-
| S4 · 카페 랜딩페이지 (UI 라운드) | 동네 카페 브랜드 랜딩페이지 만들어줘 | **LitHermes 승** | LitHermes 승 |
|
|
96
|
-
| S5 · 자료 기반 보고서와 발표자료 (오피스 라운드) | sources 폴더 자료로 보고서랑 발표자료 만들어줘 | **LitHermes 승** | LitHermes 승 |
|
|
97
|
-
| S6 · Node 22→24 조사 | Node 22에서 24로 올릴 때 달라지는 거 조사해줘 | **LitHermes 승** | 무승부 |
|
|
98
|
-
| S7 · 주문·결제·배송 구조도 | 주문-결제-배송 서비스 구조도 그려줘 | **LitHermes 승** | LitHermes 승 |
|
|
99
|
-
| S8 · 분기 실적 발표자료 (오피스 라운드) | 분기 실적 발표자료 만들어줘 | **LitHermes 승** | 기준선 승 |
|
|
100
|
-
| S9 · 신제품 기획서 (오피스 라운드) | 신제품 기획서 써줘 | **LitHermes 승** | LitHermes 승 |
|
|
101
|
-
| S11 · 회의실 예약 웹앱 (UI 라운드) | 회의실 예약 웹앱 만들어줘 | **LitHermes 승** | 무승부 |
|
|
102
|
-
| 합계 | | **8승 2무 0패** | 5승 3무 2패 |
|
|
103
|
-
|
|
104
|
-
판정자는 S1과 S8에서 기준선을 골랐습니다. 양쪽이 만든 결과물과 스크린샷, 그리고 각 작업이 어느 라운드에서 나왔는지는 GitHub에서 확인할 수 있습니다. 맨 위 모션 표지는 LitFamily 모션 스킬로 만들었습니다. 이 스킬(여기서는 `lit-typographic-motion`)은 첫 A/B 이후 다시 만들어졌고, 아직 A/B 결과가 없습니다.
|
|
88
|
+
기본값은 꺼짐이고, 퍼센트는 직접 고릅니다. `/lit-handoff auto on 60`(1에서 99 사이의 정수)을 실행하면, 모델 호출 하나가 컨텍스트 창의 60%를 넘은 뒤 첫 메시지에서 LitHermes가 모델에게 핸드오프를 요청하고 `/compact`를 실행하라고 알려 줍니다. Hermes에서는 플러그인이 압축을 시작할 수 없으므로, 사용자가 직접 실행하거나 Hermes가 자체 기준으로 압축합니다. 압축 뒤에는 핸드오프가 짧은 요약으로 돌아옵니다. `/lit-handoff auto off`와 `/lit-handoff auto status`는 이름 그대로 동작하고, 환경에서는 `LITHERMES_AUTO_HANDOFF=1`과 `LITHERMES_AUTO_HANDOFF_PERCENT`로 설정합니다. 어느 단계가 자동이고 Hermes 자체 압축과 겹치지 않는 퍼센트를 어떻게 고르는지는 [GitHub README](https://github.com/wjgoarxiv/lithermes/blob/main/README_Ko-KR.md#자동-핸드오프)에 있습니다.
|
|
105
89
|
|
|
106
90
|
## 선택 기능: Jev 스킬 힌트
|
|
107
91
|
|
|
@@ -129,7 +113,7 @@ npx --package @litfamily/lithermes -- lithermes uninstall --yes
|
|
|
129
113
|
|
|
130
114
|
## 더 알아보기
|
|
131
115
|
|
|
132
|
-
- [GitHub의 전체 README](https://github.com/wjgoarxiv/lithermes/blob/main/README_Ko-KR.md): 스킬
|
|
116
|
+
- [GitHub의 전체 README](https://github.com/wjgoarxiv/lithermes/blob/main/README_Ko-KR.md): 스킬 갤러리와 작동 방식
|
|
133
117
|
- [운영 안내](https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.ko.md): 명령, 모델, 스킨, 문제 해결
|
|
134
118
|
- [변경 기록](https://github.com/wjgoarxiv/lithermes/blob/main/CHANGELOG.md) · [기여](https://github.com/wjgoarxiv/lithermes/blob/main/CONTRIBUTING.md) · [보안](https://github.com/wjgoarxiv/lithermes/blob/main/SECURITY.md) · [지원](https://github.com/wjgoarxiv/lithermes/blob/main/SUPPORT.md)
|
|
135
119
|
|
|
@@ -135,6 +135,35 @@ CLI/plugin name, not the npm package name.
|
|
|
135
135
|
timestamp to Hermes home `lithermes/jev-last-<session hash>.json` (mode 0600,
|
|
136
136
|
atomic), one file per session, removed when the session ends. Neither file is
|
|
137
137
|
written through a symlink.
|
|
138
|
+
- Optional automatic handoff (`auto_handoff.py`), off by default. The user
|
|
139
|
+
switches it on with `/lit-handoff auto on <percent>` (plain text reply, no
|
|
140
|
+
model call; `auto off` and `auto status` likewise) or with
|
|
141
|
+
`LITHERMES_AUTO_HANDOFF=1` plus `LITHERMES_AUTO_HANDOFF_PERCENT`; the
|
|
142
|
+
environment wins over the saved value, any flag value other than `1` keeps it
|
|
143
|
+
off, and a percent that is not a whole number from 1 to 99 leaves it off with a
|
|
144
|
+
doctor warning. There is no built-in percent: `on` without a number reuses the
|
|
145
|
+
last saved one and asks when none exists. The switch is saved in Hermes home
|
|
146
|
+
`lithermes/auto-handoff.json` (mode 0600, atomic, never written through a
|
|
147
|
+
symlink). `post_api_request` reads the prompt size of each model call
|
|
148
|
+
(`usage.prompt_tokens`, else input plus cache read plus cache write) over the
|
|
149
|
+
model's context window (`model.context_length` from the Hermes config, else
|
|
150
|
+
the host's own lookup, bounded to 3 seconds) and keeps the latest reading per
|
|
151
|
+
session in memory. Crossing the percent marks one pending directive;
|
|
152
|
+
`pre_llm_call` delivers it on the next turn, merged through the post-edit
|
|
153
|
+
composition so the byte budget keeps it whole. The directive names the bundled
|
|
154
|
+
lit-handoff source, asks for an `auto-handoff-id:` line in the file and for
|
|
155
|
+
the one plain line `Handoff saved. Run /compact now.` (`/compress` on Hermes
|
|
156
|
+
0.17, which has no `/compact`). A plugin cannot start compaction here. After
|
|
157
|
+
the user compacts, or Hermes compacts on its own, the next `pre_llm_call`
|
|
158
|
+
sees a new `_compressed_summary` message, and loads a digest (Current State
|
|
159
|
+
and Next Steps, at most 1,400 bytes, redacted and escaped) of `HANDOFF.md` or
|
|
160
|
+
`.handoff/HANDOFF.md`, but only when the file carries this session's id, was
|
|
161
|
+
written after the directive and is a regular file. Anything else gets a
|
|
162
|
+
one-line refusal. It fires once per crossing, never for delegate children,
|
|
163
|
+
and forgets a session when it ends. Status and doctor print `Automatic
|
|
164
|
+
handoff: ...` and warn when the percent is at or above the estimate of
|
|
165
|
+
Hermes' own compaction point (the `compression` settings, the small-window
|
|
166
|
+
floor and the token cap when the running host has them).
|
|
138
167
|
- The `post_tool_call` hook is an observer — Hermes discards its return value — so
|
|
139
168
|
it records the paths a completed `write_file` / `patch` call mutated and
|
|
140
169
|
`pre_llm_call` renders them on the next turn. A source-code edit names
|
|
@@ -7,6 +7,7 @@ import re
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
+
from . import auto_handoff
|
|
10
11
|
from . import core
|
|
11
12
|
from . import bounded_work
|
|
12
13
|
from . import bounded_work_tools
|
|
@@ -316,6 +317,23 @@ def _merge_knowledge(
|
|
|
316
317
|
return result
|
|
317
318
|
|
|
318
319
|
|
|
320
|
+
def _auto_handoff_block(kwargs: dict[str, Any]) -> str:
|
|
321
|
+
"""The opt-in automatic-handoff directive or reload for this turn, or an empty string.
|
|
322
|
+
|
|
323
|
+
It rides with the post-edit route so the bounded composition keeps it whole,
|
|
324
|
+
and a failure here never costs the turn its other context.
|
|
325
|
+
"""
|
|
326
|
+
try:
|
|
327
|
+
return auto_handoff.pre_llm_call(
|
|
328
|
+
session_id=kwargs.get("session_id"),
|
|
329
|
+
user_message=kwargs.get("user_message"),
|
|
330
|
+
conversation_history=kwargs.get("conversation_history"),
|
|
331
|
+
platform=kwargs.get("platform"),
|
|
332
|
+
)
|
|
333
|
+
except Exception: # noqa: BLE001 - advisory only
|
|
334
|
+
return ""
|
|
335
|
+
|
|
336
|
+
|
|
319
337
|
def _pre_llm_call(**kwargs: Any) -> dict[str, str] | None:
|
|
320
338
|
"""Compose the Litwork directive and the active-litgoal snapshot.
|
|
321
339
|
|
|
@@ -333,6 +351,9 @@ def _pre_llm_call(**kwargs: Any) -> dict[str, str] | None:
|
|
|
333
351
|
kwargs.get("session_id"), kwargs.get("user_message"), kwargs.get("turn_id")
|
|
334
352
|
)
|
|
335
353
|
post_edit = core.consume_rules_context(**kwargs)
|
|
354
|
+
auto_handoff_block = _auto_handoff_block(kwargs)
|
|
355
|
+
if auto_handoff_block:
|
|
356
|
+
post_edit = f"{auto_handoff_block}\n\n{post_edit}" if post_edit else auto_handoff_block
|
|
336
357
|
workspace = kwargs.get("workspace")
|
|
337
358
|
knowledge_context = knowledge.query(workspace, str(kwargs.get("user_message") or ""))
|
|
338
359
|
base = core.pre_llm_call(**kwargs)
|
|
@@ -593,6 +614,7 @@ def _release_bounded_session(**kwargs: Any) -> None:
|
|
|
593
614
|
session_id = raw_session_id if isinstance(raw_session_id, str) else ""
|
|
594
615
|
bounded_work.release_bounded_session(session_id)
|
|
595
616
|
jev_hint.release_session(session_id)
|
|
617
|
+
auto_handoff.release_session(session_id)
|
|
596
618
|
# A finalized/reset session must not leave its rule dedup ledger behind: the
|
|
597
619
|
# next session reusing the id would see every rule as "already injected".
|
|
598
620
|
core.release_rules_session(session_id)
|
|
@@ -708,8 +730,8 @@ def register(ctx) -> None:
|
|
|
708
730
|
ctx.register_command(
|
|
709
731
|
"lit-handoff",
|
|
710
732
|
_command_handler(ctx, handoff.command_lit_handoff),
|
|
711
|
-
description="Create or update a verified LitHermes continuation handoff",
|
|
712
|
-
args_hint="[focus]",
|
|
733
|
+
description="Create or update a verified LitHermes continuation handoff, or set the automatic handoff",
|
|
734
|
+
args_hint="[focus] | auto on <percent> | auto off | auto status",
|
|
713
735
|
)
|
|
714
736
|
ctx.register_command(
|
|
715
737
|
"lit-scientific-visualization",
|
|
@@ -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
|
+
])
|
|
@@ -76,6 +76,11 @@ try:
|
|
|
76
76
|
except (ImportError, ModuleNotFoundError):
|
|
77
77
|
import auto_update as _auto_update
|
|
78
78
|
|
|
79
|
+
try:
|
|
80
|
+
from . import auto_handoff as _auto_handoff
|
|
81
|
+
except (ImportError, ModuleNotFoundError):
|
|
82
|
+
import auto_handoff as _auto_handoff
|
|
83
|
+
|
|
79
84
|
try:
|
|
80
85
|
from . import output_styles as _output_styles
|
|
81
86
|
except (ImportError, ModuleNotFoundError):
|
|
@@ -155,6 +160,10 @@ def post_api_request(**kwargs: Any) -> None:
|
|
|
155
160
|
fields, but the measurement module intentionally ignores them. Returning
|
|
156
161
|
``None`` preserves the observer-only hook contract.
|
|
157
162
|
"""
|
|
163
|
+
try:
|
|
164
|
+
_auto_handoff.post_api_request(**kwargs)
|
|
165
|
+
except Exception: # noqa: BLE001 - the optional handoff reading never costs a model call
|
|
166
|
+
pass
|
|
158
167
|
return _provider_metrics.post_api_request(**kwargs)
|
|
159
168
|
|
|
160
169
|
|
|
@@ -191,6 +200,7 @@ def on_session_start(**kwargs: Any) -> None:
|
|
|
191
200
|
release_browser_drive_state(kwargs.get("session_id"))
|
|
192
201
|
_deliverable_hedges.release_session(kwargs.get("session_id"))
|
|
193
202
|
_rules.begin_session(str(kwargs.get("session_id") or ""))
|
|
203
|
+
_auto_handoff.begin_session(kwargs.get("session_id"))
|
|
194
204
|
_print_agents_nudge(str(kwargs.get("platform") or ""))
|
|
195
205
|
return None
|
|
196
206
|
|
|
@@ -26,6 +26,11 @@ try:
|
|
|
26
26
|
except (ImportError, ModuleNotFoundError):
|
|
27
27
|
import jev_hint as _jev_hint # type: ignore
|
|
28
28
|
|
|
29
|
+
try:
|
|
30
|
+
from . import auto_handoff as _auto_handoff
|
|
31
|
+
except (ImportError, ModuleNotFoundError):
|
|
32
|
+
import auto_handoff as _auto_handoff # type: ignore
|
|
33
|
+
|
|
29
34
|
try:
|
|
30
35
|
from .core_contract import KOREAN_PROSE_ALIASES, KOREAN_PROSE_COMMANDS
|
|
31
36
|
except (ImportError, ModuleNotFoundError):
|
|
@@ -483,6 +488,7 @@ def status_report(host_version: str | None = None) -> str:
|
|
|
483
488
|
_knowledge.status_line(Path.cwd()),
|
|
484
489
|
_provider_metrics.capability_line(),
|
|
485
490
|
_jev_hint.status_line(),
|
|
491
|
+
_auto_handoff.status_line(),
|
|
486
492
|
f"litgoal evidence kinds: {', '.join(_evidence_kinds())}",
|
|
487
493
|
f"scientific visualization: {_science.dependency_summary()}",
|
|
488
494
|
*_motion_runtime_lines(),
|
|
@@ -571,6 +577,8 @@ def doctor_report(host_version: str | None = None) -> tuple[list[str], int]:
|
|
|
571
577
|
jev_state = _jev_hint.status()
|
|
572
578
|
jev_tag = {"on": "OK", "off": "NOTE"}.get(jev_state, "WARN")
|
|
573
579
|
lines.append(f"[{jev_tag}] {_jev_hint.status_line()}")
|
|
580
|
+
handoff_tag, handoff_text = _auto_handoff.doctor_line()
|
|
581
|
+
lines.append(f"[{handoff_tag}] {handoff_text}")
|
|
574
582
|
if version != "unknown":
|
|
575
583
|
lines.append(f"[OK] Hermes host detected (v{version})")
|
|
576
584
|
else:
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import html
|
|
4
|
+
import os
|
|
4
5
|
import re
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
try:
|
|
10
|
+
from . import auto_handoff
|
|
9
11
|
from .core_contract import (
|
|
10
12
|
reader_facing_contract_block,
|
|
11
13
|
reader_facing_mode_from_user_request,
|
|
@@ -14,6 +16,7 @@ try:
|
|
|
14
16
|
from .redaction import redact_text
|
|
15
17
|
from .session_context import is_delegate_child_platform
|
|
16
18
|
except (ImportError, ModuleNotFoundError):
|
|
19
|
+
import auto_handoff # type: ignore
|
|
17
20
|
from core_contract import ( # type: ignore
|
|
18
21
|
reader_facing_contract_block,
|
|
19
22
|
reader_facing_mode_from_user_request,
|
|
@@ -65,8 +68,16 @@ def _agent_message(raw_args: str) -> str:
|
|
|
65
68
|
)
|
|
66
69
|
|
|
67
70
|
|
|
68
|
-
def command_lit_handoff(raw_args: str) -> dict[str, str]:
|
|
69
|
-
"""Build
|
|
71
|
+
def command_lit_handoff(raw_args: str) -> dict[str, str] | str:
|
|
72
|
+
"""Build model context for the native slash command.
|
|
73
|
+
|
|
74
|
+
``auto on <percent> | off | status`` is the user's switch for the automatic
|
|
75
|
+
handoff; it answers in plain text and sends nothing to the model. Every
|
|
76
|
+
other argument is a focus for a normal handoff and stays side-effect-free.
|
|
77
|
+
"""
|
|
78
|
+
switch = auto_handoff.parse_route(raw_args)
|
|
79
|
+
if switch is not None:
|
|
80
|
+
return auto_handoff.run_command(switch, session_id=os.environ.get(auto_handoff.SESSION_ENV, "").strip())
|
|
70
81
|
return {
|
|
71
82
|
"display": f"{acknowledgement('lit-handoff', color=True)}\nHandoff source loaded; inspect live state before writing.",
|
|
72
83
|
"agent_message": _agent_message(raw_args),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"syncedAt": "2026-09-
|
|
2
|
+
"syncedAt": "2026-09-30T09:04:54.689Z",
|
|
3
3
|
"source": "bundled-payload",
|
|
4
|
-
"sourceHash": "
|
|
4
|
+
"sourceHash": "0c8bc244aece11b11cfa6b99809f218689f5d445b98e579b467910ed84897ec6",
|
|
5
5
|
"files": [
|
|
6
6
|
{
|
|
7
7
|
"path": "NOTICE.md",
|
|
@@ -9,11 +9,15 @@
|
|
|
9
9
|
},
|
|
10
10
|
{
|
|
11
11
|
"path": "README.md",
|
|
12
|
-
"sha256": "
|
|
12
|
+
"sha256": "23af46fe9078058d97f14fcde0aacc7b615575ec38b75cfee0d959649eb7b3d2"
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"path": "__init__.py",
|
|
16
|
-
"sha256": "
|
|
16
|
+
"sha256": "a3393c5776bc7557345de5623e58cb966c0154b3972111907a5ad9954165a0d9"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "auto_handoff.py",
|
|
20
|
+
"sha256": "4a34c6b1ee153a405943a87c74fc09832da3b9d5ca4b9b50acf32cd0f2a4fe19"
|
|
17
21
|
},
|
|
18
22
|
{
|
|
19
23
|
"path": "auto_update.py",
|
|
@@ -29,7 +33,7 @@
|
|
|
29
33
|
},
|
|
30
34
|
{
|
|
31
35
|
"path": "core.py",
|
|
32
|
-
"sha256": "
|
|
36
|
+
"sha256": "32b7ef2c93e3f208e39e26a6eca4a03f48804b7ddf6b7a16eaf4cf038fbf4e21"
|
|
33
37
|
},
|
|
34
38
|
{
|
|
35
39
|
"path": "core_commands.py",
|
|
@@ -85,11 +89,11 @@
|
|
|
85
89
|
},
|
|
86
90
|
{
|
|
87
91
|
"path": "diagnostics.py",
|
|
88
|
-
"sha256": "
|
|
92
|
+
"sha256": "4c70cdfc9a3bb70cbf319058592316110cd8a6d1b52d5a11a28c16e29cb4f645"
|
|
89
93
|
},
|
|
90
94
|
{
|
|
91
95
|
"path": "handoff.py",
|
|
92
|
-
"sha256": "
|
|
96
|
+
"sha256": "f34788ee4b56b6156e6503b546ea416fd886856df2c8f9e60006eb2b0289a027"
|
|
93
97
|
},
|
|
94
98
|
{
|
|
95
99
|
"path": "humanizer_detector.py",
|
|
@@ -177,7 +181,7 @@
|
|
|
177
181
|
},
|
|
178
182
|
{
|
|
179
183
|
"path": "plugin.yaml",
|
|
180
|
-
"sha256": "
|
|
184
|
+
"sha256": "86bdae4be0a711f4ed4c373c9a38d63a7d32f5d0d7b35dac1c5ed828f5611d6b"
|
|
181
185
|
},
|
|
182
186
|
{
|
|
183
187
|
"path": "provider_metrics.py",
|
|
@@ -1281,7 +1285,7 @@
|
|
|
1281
1285
|
},
|
|
1282
1286
|
{
|
|
1283
1287
|
"path": "skills/frontend-ui-ux/references/complete-contract.md",
|
|
1284
|
-
"sha256": "
|
|
1288
|
+
"sha256": "c716c333d0c8304769ea8504f4231072ba26525312c306efddc267ce546c2af0"
|
|
1285
1289
|
},
|
|
1286
1290
|
{
|
|
1287
1291
|
"path": "skills/frontend-ui-ux/references/composition.md",
|
|
@@ -3157,7 +3161,7 @@
|
|
|
3157
3161
|
},
|
|
3158
3162
|
{
|
|
3159
3163
|
"path": "skills/lit-handoff/SKILL.md",
|
|
3160
|
-
"sha256": "
|
|
3164
|
+
"sha256": "d49141a84537cfa5dc36be0adb72fc57fb6ab4af97c1a75e82b84b4fa5983dd6"
|
|
3161
3165
|
},
|
|
3162
3166
|
{
|
|
3163
3167
|
"path": "skills/lit-humanizer/NOTICE",
|
|
@@ -4149,7 +4153,7 @@
|
|
|
4149
4153
|
},
|
|
4150
4154
|
{
|
|
4151
4155
|
"path": "skills/visual-qa/references/complete-contract.md",
|
|
4152
|
-
"sha256": "
|
|
4156
|
+
"sha256": "813f97453109dffa89aca3602db39ae7d69fb874cf867e2dfb002612cbf730b4"
|
|
4153
4157
|
},
|
|
4154
4158
|
{
|
|
4155
4159
|
"path": "skills/visual-qa/schemas/evidence-manifest-v1alpha1.schema.json",
|
|
@@ -445,7 +445,7 @@ isolated Hermes home. Use `--hermes-home`; never `--home`.
|
|
|
445
445
|
|
|
446
446
|
```text
|
|
447
447
|
$ node packages/lithermes-installer/bin/lithermes.js install --yes --offline --no-hud --hermes-home "$ISOLATED"
|
|
448
|
-
Installed LitHermes 1.0.
|
|
448
|
+
Installed LitHermes 1.0.14
|
|
449
449
|
plugin: $ISOLATED/plugins/lithermes
|
|
450
450
|
model config: updated
|
|
451
451
|
|
|
@@ -462,7 +462,7 @@ installed payload: PASS
|
|
|
462
462
|
enabled config: PASS
|
|
463
463
|
|
|
464
464
|
$ hermes lithermes doctor
|
|
465
|
-
[OK] plugin.yaml readable (version 1.0.
|
|
465
|
+
[OK] plugin.yaml readable (version 1.0.14)
|
|
466
466
|
[OK] skills bundled: 36
|
|
467
467
|
[OK] litgoal durable runtime importable
|
|
468
468
|
```
|
|
@@ -62,6 +62,7 @@ on its own, before any other reply content:
|
|
|
62
62
|
| direct skill | explicit `lithermes:lit-handoff` | Read this adapter and then the original source in full. | Do not invent state that was not inspected. |
|
|
63
63
|
| slash command | `/lit-handoff [focus]` | Inject the exact source plus a redacted focus as inert data. | The command handler itself must not write workspace files. |
|
|
64
64
|
| exact natural route | the complete user message is `handoff` | Use the same source and named banner exactly once for that turn. | Near-misses, code spans, paths, and child-agent messages must not activate. |
|
|
65
|
+
| automatic handoff | the user switched it on with `/lit-handoff auto on <percent>` or `LITHERMES_AUTO_HANDOFF=1` plus `LITHERMES_AUTO_HANDOFF_PERCENT`, and a model call passed that percent | `auto_handoff.py` adds one `<lithermes-auto-handoff>` block to the next turn. Follow the original source, put the `auto-handoff-id:` line from the block near the top of the file, and tell the user the one plain line the block names. | Off by default, no built-in percent, one directive per crossing, delegate children never receive it. After a compaction the next turn gets a bounded digest, and only a file that carries this session's id and was written after the block is loaded. |
|
|
65
66
|
|
|
66
67
|
## #contract.procedure
|
|
67
68
|
|
|
@@ -138,6 +139,12 @@ limitations_channel: inline
|
|
|
138
139
|
|
|
139
140
|
# Hermes Adapter Notes
|
|
140
141
|
|
|
142
|
+
`auto_handoff.py` owns the switch and the automatic path: `/lit-handoff auto on
|
|
143
|
+
<percent>`, `auto off` and `auto status` answer in plain text without a model
|
|
144
|
+
call, and `status` and `doctor` print an `Automatic handoff` line. A plugin
|
|
145
|
+
cannot start compaction on Hermes, so the user runs the compact command, or
|
|
146
|
+
Hermes compacts on its own threshold.
|
|
147
|
+
|
|
141
148
|
`handoff.py` resolves the installed source root at runtime, reads the source
|
|
142
149
|
without rewriting it, and injects it as authoritative bundled guidance. The
|
|
143
150
|
slash command and exact natural route are side-effect-free until the model has
|
|
@@ -485,7 +485,7 @@ isolated Hermes home. Use `--hermes-home`; never `--home`.
|
|
|
485
485
|
|
|
486
486
|
```text
|
|
487
487
|
$ node packages/lithermes-installer/bin/lithermes.js install --yes --offline --no-hud --hermes-home "$ISOLATED"
|
|
488
|
-
Installed LitHermes 1.0.
|
|
488
|
+
Installed LitHermes 1.0.14
|
|
489
489
|
plugin: $ISOLATED/plugins/lithermes
|
|
490
490
|
model config: updated
|
|
491
491
|
|
|
@@ -502,7 +502,7 @@ installed payload: PASS
|
|
|
502
502
|
enabled config: PASS
|
|
503
503
|
|
|
504
504
|
$ hermes lithermes doctor
|
|
505
|
-
[OK] plugin.yaml readable (version 1.0.
|
|
505
|
+
[OK] plugin.yaml readable (version 1.0.14)
|
|
506
506
|
[OK] skills bundled: 36
|
|
507
507
|
[OK] litgoal durable runtime importable
|
|
508
508
|
```
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.14"><title>release: 1.0.14</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.14</text></g></svg>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|