context-handoff 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- context_handoff/__init__.py +3 -0
- context_handoff/__main__.py +58 -0
- context_handoff/config.py +90 -0
- context_handoff/meter.py +177 -0
- context_handoff/prices.py +66 -0
- context_handoff/report.py +50 -0
- context_handoff/resume.py +63 -0
- context_handoff/transcript.py +97 -0
- context_handoff-0.1.1.dist-info/METADATA +182 -0
- context_handoff-0.1.1.dist-info/RECORD +14 -0
- context_handoff-0.1.1.dist-info/WHEEL +5 -0
- context_handoff-0.1.1.dist-info/entry_points.txt +2 -0
- context_handoff-0.1.1.dist-info/licenses/LICENSE +21 -0
- context_handoff-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""context-handoff entry point.
|
|
2
|
+
|
|
3
|
+
context-handoff meter UserPromptSubmit hook (reads hook JSON on stdin)
|
|
4
|
+
context-handoff resume SessionStart hook (reads hook JSON on stdin)
|
|
5
|
+
context-handoff report [days]
|
|
6
|
+
context-handoff check <transcript.jsonl> print what the meter sees
|
|
7
|
+
|
|
8
|
+
Hooks never fail the session: any error ends with no output and exit 0.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from . import config, meter, report, resume
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _hook(fn) -> int:
|
|
20
|
+
try:
|
|
21
|
+
event = json.load(sys.stdin)
|
|
22
|
+
out = fn(event, config.load())
|
|
23
|
+
except Exception as exc: # a hook must never break the session
|
|
24
|
+
print(f"context-handoff: {exc}", file=sys.stderr)
|
|
25
|
+
return 0
|
|
26
|
+
if out:
|
|
27
|
+
print(json.dumps(out))
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(argv: list[str] | None = None) -> int:
|
|
32
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
33
|
+
cmd = argv[0] if argv else ""
|
|
34
|
+
if cmd == "meter":
|
|
35
|
+
return _hook(meter.run)
|
|
36
|
+
if cmd == "resume":
|
|
37
|
+
return _hook(resume.run)
|
|
38
|
+
if cmd == "report":
|
|
39
|
+
days = int(argv[1]) if len(argv) > 1 else 7
|
|
40
|
+
print(report.render(config.load(), days))
|
|
41
|
+
return 0
|
|
42
|
+
if cmd == "check" and len(argv) > 1:
|
|
43
|
+
cfg = config.load()
|
|
44
|
+
r = meter.measure(argv[1], cfg)
|
|
45
|
+
if r is None:
|
|
46
|
+
print("no Claude replies in that transcript")
|
|
47
|
+
return 1
|
|
48
|
+
block, warn = meter.reasons(r, cfg)
|
|
49
|
+
print(r.summary())
|
|
50
|
+
print("block:", "; ".join(block) or "no")
|
|
51
|
+
print("warn: ", "; ".join(warn) or "no")
|
|
52
|
+
return 0
|
|
53
|
+
print(__doc__.strip())
|
|
54
|
+
return 2
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
sys.exit(main())
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Settings: defaults, then ~/.claude/context-handoff.json, then environment.
|
|
2
|
+
|
|
3
|
+
Config file example (all keys optional):
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"warn_k": 150, "block_k": 400,
|
|
7
|
+
"warn_turn_usd": 0.50, "block_turn_usd": 3.00,
|
|
8
|
+
"warn_session_usd": 20, "auto_handoff": true,
|
|
9
|
+
"prices": {"claude-opus-5-5": [4.0, 20.0, 0.20]}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
Environment variables use the same names, upper case, with a
|
|
13
|
+
CONTEXT_HANDOFF_ prefix: CONTEXT_HANDOFF_WARN_TURN_USD=1.0 and so on.
|
|
14
|
+
A limit set to 0 is turned off.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from dataclasses import dataclass, field, fields
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from .prices import DEFAULT_PRICES
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _home() -> Path:
|
|
28
|
+
return Path(os.environ.get("HOME") or Path.home())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Config:
|
|
33
|
+
warn_k: int = 150 # context size (thousands of tokens) that triggers a handoff
|
|
34
|
+
block_k: int = 400 # context size that blocks prompts until a handoff
|
|
35
|
+
warn_turn_usd: float = 0.50 # cost of the last turn that triggers a handoff
|
|
36
|
+
block_turn_usd: float = 3.00 # cost of the last turn that blocks prompts
|
|
37
|
+
warn_session_usd: float = 20.0 # whole-session cost that triggers a handoff
|
|
38
|
+
auto_handoff: bool = True # ask Claude to write the note itself
|
|
39
|
+
max_age_min: int = 180 # notes older than this are not resumed
|
|
40
|
+
handoff_dir: str = ""
|
|
41
|
+
state_dir: str = ""
|
|
42
|
+
prices: dict = field(default_factory=lambda: dict(DEFAULT_PRICES))
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def notes(self) -> Path:
|
|
46
|
+
return Path(self.handoff_dir) if self.handoff_dir else _home() / ".claude" / "handoffs"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def state(self) -> Path:
|
|
50
|
+
return Path(self.state_dir) if self.state_dir else _home() / ".claude" / "context-handoff"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _coerce(kind, value):
|
|
54
|
+
if kind is bool or kind == "bool":
|
|
55
|
+
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
|
56
|
+
if kind is int or kind == "int":
|
|
57
|
+
return int(float(value))
|
|
58
|
+
if kind is float or kind == "float":
|
|
59
|
+
return float(value)
|
|
60
|
+
return str(value)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load(path: str | Path | None = None) -> Config:
|
|
64
|
+
cfg = Config()
|
|
65
|
+
path = Path(path) if path else Path(
|
|
66
|
+
os.environ.get("CONTEXT_HANDOFF_CONFIG") or _home() / ".claude" / "context-handoff.json"
|
|
67
|
+
)
|
|
68
|
+
data: dict = {}
|
|
69
|
+
if path.is_file():
|
|
70
|
+
try:
|
|
71
|
+
data = json.loads(path.read_text())
|
|
72
|
+
except (OSError, ValueError):
|
|
73
|
+
data = {}
|
|
74
|
+
for f in fields(Config):
|
|
75
|
+
if f.name == "prices":
|
|
76
|
+
continue
|
|
77
|
+
env = os.environ.get("CONTEXT_HANDOFF_" + f.name.upper())
|
|
78
|
+
raw = env if env not in (None, "") else data.get(f.name)
|
|
79
|
+
if raw is not None:
|
|
80
|
+
try:
|
|
81
|
+
setattr(cfg, f.name, _coerce(f.type, raw))
|
|
82
|
+
except (TypeError, ValueError):
|
|
83
|
+
pass
|
|
84
|
+
for prefix, row in (data.get("prices") or {}).items():
|
|
85
|
+
try:
|
|
86
|
+
i, o, r = (float(x) for x in row)
|
|
87
|
+
cfg.prices[prefix] = (i, o, r)
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
pass
|
|
90
|
+
return cfg
|
context_handoff/meter.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""UserPromptSubmit hook: decide whether this session has become too expensive.
|
|
2
|
+
|
|
3
|
+
Every turn re-sends the whole conversation. While the prompt cache is warm the
|
|
4
|
+
re-read is cheap; after a pause the whole context is written again at the
|
|
5
|
+
cache-write price, and that is where long sessions get expensive. The meter
|
|
6
|
+
looks at three numbers from the transcript:
|
|
7
|
+
|
|
8
|
+
* context: tokens the last reply read (what the next turn re-sends)
|
|
9
|
+
* last turn: what the last reply cost
|
|
10
|
+
* session: what all replies in this session cost so far
|
|
11
|
+
|
|
12
|
+
and also projects what one cold turn (cache expired) would cost now.
|
|
13
|
+
|
|
14
|
+
Over a warn limit it asks Claude, once per session, to write a handoff note
|
|
15
|
+
when the current task is done. Over a block limit it blocks the prompt until
|
|
16
|
+
the owner asks for the handoff (prompt starts with "handoff") or overrides it
|
|
17
|
+
("#override").
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import datetime as dt
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from . import transcript
|
|
29
|
+
from .config import Config
|
|
30
|
+
from .prices import lookup
|
|
31
|
+
|
|
32
|
+
BYPASS = re.compile(r"^\s*/?([a-z0-9-]+:)?handoff\b|#override", re.IGNORECASE)
|
|
33
|
+
# The plugin's /handoff command defines the note's format; the meter reuses it.
|
|
34
|
+
COMMAND_FILE = Path(__file__).resolve().parents[2] / "commands" / "handoff.md"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Reading:
|
|
39
|
+
model: str
|
|
40
|
+
context: int
|
|
41
|
+
last_turn_usd: float
|
|
42
|
+
session_usd: float
|
|
43
|
+
cold_turn_usd: float
|
|
44
|
+
turns: int
|
|
45
|
+
|
|
46
|
+
def summary(self) -> str:
|
|
47
|
+
return (
|
|
48
|
+
f"{self.context // 1000}K context, last turn ${self.last_turn_usd:.2f}, "
|
|
49
|
+
f"session ${self.session_usd:.2f}, a cold turn would cost ${self.cold_turn_usd:.2f} "
|
|
50
|
+
f"({self.model} prices)"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def measure(path: str, cfg: Config) -> Reading | None:
|
|
55
|
+
session = transcript.read(path)
|
|
56
|
+
last = session.last
|
|
57
|
+
if last is None:
|
|
58
|
+
return None
|
|
59
|
+
price = lookup(last.model, cfg.prices)
|
|
60
|
+
return Reading(
|
|
61
|
+
model=price.model,
|
|
62
|
+
context=last.context,
|
|
63
|
+
last_turn_usd=last.cost(price),
|
|
64
|
+
session_usd=session.cost(cfg.prices),
|
|
65
|
+
cold_turn_usd=last.context * price.write_1h / 1e6,
|
|
66
|
+
turns=len(session.turns),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _over(value: float, limit: float) -> bool:
|
|
71
|
+
return limit > 0 and value >= limit
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def reasons(r: Reading, cfg: Config) -> tuple[list[str], list[str]]:
|
|
75
|
+
"""(block reasons, warn reasons)."""
|
|
76
|
+
block, warn = [], []
|
|
77
|
+
if _over(r.context, cfg.block_k * 1000):
|
|
78
|
+
block.append(f"context {r.context // 1000}K >= {cfg.block_k}K")
|
|
79
|
+
if _over(r.last_turn_usd, cfg.block_turn_usd):
|
|
80
|
+
block.append(f"last turn ${r.last_turn_usd:.2f} >= ${cfg.block_turn_usd:.2f}")
|
|
81
|
+
if _over(r.context, cfg.warn_k * 1000):
|
|
82
|
+
warn.append(f"context {r.context // 1000}K >= {cfg.warn_k}K")
|
|
83
|
+
if _over(r.last_turn_usd, cfg.warn_turn_usd):
|
|
84
|
+
warn.append(f"last turn ${r.last_turn_usd:.2f} >= ${cfg.warn_turn_usd:.2f}")
|
|
85
|
+
if _over(r.session_usd, cfg.warn_session_usd):
|
|
86
|
+
warn.append(f"session ${r.session_usd:.2f} >= ${cfg.warn_session_usd:.2f}")
|
|
87
|
+
return block, warn
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def note_path(cfg: Config, cwd: str, now: dt.datetime | None = None) -> Path:
|
|
91
|
+
now = now or dt.datetime.now()
|
|
92
|
+
project = Path(cwd).name or "session"
|
|
93
|
+
return cfg.notes / f"{project}-{now:%Y%m%d-%H%M}.md"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def note_instructions() -> str:
|
|
97
|
+
"""The handoff command's body: the one place the note's format is defined."""
|
|
98
|
+
try:
|
|
99
|
+
text = COMMAND_FILE.read_text()
|
|
100
|
+
except OSError:
|
|
101
|
+
return "Write the goal, state, decisions, open items and next steps, where things are, and gotchas."
|
|
102
|
+
if text.startswith("---"):
|
|
103
|
+
text = text.split("---", 2)[2]
|
|
104
|
+
return text.replace("$ARGUMENTS", "").strip()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _log(cfg: Config, event: dict) -> None:
|
|
108
|
+
try:
|
|
109
|
+
cfg.state.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
with open(cfg.state / "meter.jsonl", "a") as f:
|
|
111
|
+
f.write(json.dumps(event) + "\n")
|
|
112
|
+
except OSError:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def run(event: dict, cfg: Config) -> dict | None:
|
|
117
|
+
"""Hook output as a dict, or None for no output."""
|
|
118
|
+
path = event.get("transcript_path") or ""
|
|
119
|
+
session = event.get("session_id") or ""
|
|
120
|
+
cwd = event.get("cwd") or ""
|
|
121
|
+
prompt = event.get("prompt") or ""
|
|
122
|
+
if not path or not Path(path).is_file():
|
|
123
|
+
return None
|
|
124
|
+
r = measure(path, cfg)
|
|
125
|
+
if r is None:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
_log(cfg, {
|
|
129
|
+
"ts": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"),
|
|
130
|
+
"session": session, "cwd": cwd, "model": r.model, "ctx": r.context,
|
|
131
|
+
"turn_usd": round(r.last_turn_usd, 4), "session_usd": round(r.session_usd, 4),
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
block, warn = reasons(r, cfg)
|
|
135
|
+
if BYPASS.search(prompt):
|
|
136
|
+
if block or warn:
|
|
137
|
+
return {"systemMessage": f"[context-handoff] {r.summary()}; prompt let through."}
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
if block:
|
|
141
|
+
return {
|
|
142
|
+
"decision": "block",
|
|
143
|
+
"reason": (
|
|
144
|
+
f"[context-handoff] {'; '.join(block)}. {r.summary()}. Every turn re-sends the whole "
|
|
145
|
+
"session. Type: handoff (Claude saves a note), then /clear and type go. "
|
|
146
|
+
"Add #override to send this prompt anyway."
|
|
147
|
+
),
|
|
148
|
+
}
|
|
149
|
+
if not warn:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
marker = cfg.state / f"asked-{session}"
|
|
153
|
+
if cfg.auto_handoff and session and not marker.exists():
|
|
154
|
+
try:
|
|
155
|
+
cfg.state.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
marker.touch()
|
|
157
|
+
except OSError:
|
|
158
|
+
pass
|
|
159
|
+
target = note_path(cfg, cwd)
|
|
160
|
+
context = (
|
|
161
|
+
f"[context-handoff] This session has become expensive: {'; '.join(warn)}. {r.summary()}. "
|
|
162
|
+
"Answer the owner's message first. Then, at the end of the current task (or now, if "
|
|
163
|
+
f"nothing is in progress), write a handoff note to {target} following these "
|
|
164
|
+
"instructions:\n\n" + note_instructions()
|
|
165
|
+
)
|
|
166
|
+
return {
|
|
167
|
+
"systemMessage": (
|
|
168
|
+
f"[context-handoff] {r.summary()}. Claude will write a handoff note after this "
|
|
169
|
+
"task; then /clear and type go."
|
|
170
|
+
),
|
|
171
|
+
"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": context},
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
"systemMessage": (
|
|
175
|
+
f"[context-handoff] {r.summary()}. Finish this task, type handoff, then /clear and go."
|
|
176
|
+
)
|
|
177
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Default per-million-token prices (USD) for Claude models on the Anthropic API.
|
|
2
|
+
|
|
3
|
+
Source: Anthropic's published first-party prices as of November 2026. Prices
|
|
4
|
+
change; override them in the config file (see config.py) rather than trusting
|
|
5
|
+
these forever. Cache writes cost 1.25x input (5-minute TTL) or 2x input
|
|
6
|
+
(1-hour TTL); cache reads have their own rate per model.
|
|
7
|
+
|
|
8
|
+
Each entry: model-id prefix -> (input, output, cache_read).
|
|
9
|
+
The longest matching prefix wins, so "claude-opus-4-8" beats "claude-opus-4".
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
DEFAULT_PRICES: dict[str, tuple[float, float, float]] = {
|
|
17
|
+
"claude-fable-5-2": (8.00, 40.00, 0.20),
|
|
18
|
+
"claude-mythos-5-2": (8.00, 40.00, 0.20),
|
|
19
|
+
"claude-fable-5-1": (10.00, 50.00, 0.25),
|
|
20
|
+
"claude-mythos-5-1": (10.00, 50.00, 0.25),
|
|
21
|
+
"claude-fable-5": (10.00, 50.00, 1.00),
|
|
22
|
+
"claude-mythos-5": (10.00, 50.00, 1.00),
|
|
23
|
+
"claude-opus-5-5": (4.00, 20.00, 0.20),
|
|
24
|
+
"claude-opus-5": (5.00, 25.00, 0.50),
|
|
25
|
+
"claude-opus-4": (5.00, 25.00, 0.50),
|
|
26
|
+
"claude-sonnet-5-5": (2.00, 10.00, 0.10),
|
|
27
|
+
"claude-sonnet-5": (2.00, 10.00, 0.20),
|
|
28
|
+
"claude-sonnet-4": (3.00, 15.00, 0.30),
|
|
29
|
+
"claude-haiku-5-5": (0.10, 0.50, 0.01),
|
|
30
|
+
"claude-haiku-4": (1.00, 5.00, 0.10),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Used when the model id matches nothing above: priced like the default Opus.
|
|
34
|
+
FALLBACK = "claude-opus-5-5"
|
|
35
|
+
|
|
36
|
+
WRITE_5M = 1.25
|
|
37
|
+
WRITE_1H = 2.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Price:
|
|
42
|
+
model: str
|
|
43
|
+
input: float
|
|
44
|
+
output: float
|
|
45
|
+
cache_read: float
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def write_5m(self) -> float:
|
|
49
|
+
return self.input * WRITE_5M
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def write_1h(self) -> float:
|
|
53
|
+
return self.input * WRITE_1H
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def lookup(model: str, table: dict[str, tuple[float, float, float]] | None = None) -> Price:
|
|
57
|
+
table = table or DEFAULT_PRICES
|
|
58
|
+
model = (model or "").lower()
|
|
59
|
+
best = ""
|
|
60
|
+
for prefix in table:
|
|
61
|
+
if model.startswith(prefix) and len(prefix) > len(best):
|
|
62
|
+
best = prefix
|
|
63
|
+
if not best:
|
|
64
|
+
best = FALLBACK if FALLBACK in table else next(iter(table))
|
|
65
|
+
i, o, r = table[best]
|
|
66
|
+
return Price(best, i, o, r)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""What Claude Code sessions cost, from local transcripts (API list prices)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import transcript
|
|
9
|
+
from .config import Config
|
|
10
|
+
from .prices import lookup
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def rows(cfg: Config, days: int, root: Path | None = None):
|
|
14
|
+
root = root or Path.home() / ".claude" / "projects"
|
|
15
|
+
cutoff = time.time() - days * 86400
|
|
16
|
+
for f in root.glob("*/*.jsonl"):
|
|
17
|
+
try:
|
|
18
|
+
if f.stat().st_mtime < cutoff:
|
|
19
|
+
continue
|
|
20
|
+
except OSError:
|
|
21
|
+
continue
|
|
22
|
+
s = transcript.read(f)
|
|
23
|
+
if not s.turns:
|
|
24
|
+
continue
|
|
25
|
+
peak = max(t.context for t in s.turns)
|
|
26
|
+
cold = s.last.context * lookup(s.last.model, cfg.prices).write_1h / 1e6
|
|
27
|
+
yield {
|
|
28
|
+
"project": f.parent.name, "session": f.stem[:8], "turns": len(s.turns),
|
|
29
|
+
"usd": s.cost(cfg.prices), "peak_k": peak // 1000, "cold_usd": cold,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render(cfg: Config, days: int = 7, root: Path | None = None, top: int = 15) -> str:
|
|
34
|
+
data = sorted(rows(cfg, days, root), key=lambda r: r["usd"], reverse=True)
|
|
35
|
+
if not data:
|
|
36
|
+
return f"no sessions in the last {days} days"
|
|
37
|
+
total = sum(r["usd"] for r in data)
|
|
38
|
+
over = sum(1 for r in data if r["peak_k"] >= cfg.warn_k)
|
|
39
|
+
out = [
|
|
40
|
+
f"last {days} days: {len(data)} sessions, ${total:.2f} at API list prices, "
|
|
41
|
+
f"{over} sessions peaked over {cfg.warn_k}K context",
|
|
42
|
+
"",
|
|
43
|
+
f"{'usd':>9} {'turns':>6} {'peak(K)':>8} {'cold turn':>10} project / session",
|
|
44
|
+
]
|
|
45
|
+
for r in data[:top]:
|
|
46
|
+
out.append(
|
|
47
|
+
f"{r['usd']:>9.2f} {r['turns']:>6} {r['peak_k']:>8} {r['cold_usd']:>10.2f} "
|
|
48
|
+
f"{r['project']} / {r['session']}"
|
|
49
|
+
)
|
|
50
|
+
return "\n".join(out)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""SessionStart hook (after /clear): load the project's newest unused handoff note."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .config import Config
|
|
10
|
+
|
|
11
|
+
MAX_BYTES = 12_000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def find_note(cfg: Config, cwd: str, now: float | None = None) -> Path | None:
|
|
15
|
+
notes = cfg.notes
|
|
16
|
+
if not cwd or not notes.is_dir():
|
|
17
|
+
return None
|
|
18
|
+
project = Path(cwd).name
|
|
19
|
+
pattern = re.compile(re.escape(project) + r"-\d{8}-\d{4}\.md$")
|
|
20
|
+
used = _used(cfg)
|
|
21
|
+
now = now or time.time()
|
|
22
|
+
candidates = []
|
|
23
|
+
for p in notes.iterdir():
|
|
24
|
+
if not pattern.fullmatch(p.name) or str(p) in used:
|
|
25
|
+
continue
|
|
26
|
+
try:
|
|
27
|
+
mtime = p.stat().st_mtime
|
|
28
|
+
except OSError:
|
|
29
|
+
continue
|
|
30
|
+
if cfg.max_age_min > 0 and now - mtime > cfg.max_age_min * 60:
|
|
31
|
+
continue
|
|
32
|
+
candidates.append((mtime, p))
|
|
33
|
+
return max(candidates)[1] if candidates else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _used(cfg: Config) -> set[str]:
|
|
37
|
+
try:
|
|
38
|
+
return set((cfg.notes / ".used").read_text().split("\n"))
|
|
39
|
+
except OSError:
|
|
40
|
+
return set()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run(event: dict, cfg: Config) -> dict | None:
|
|
44
|
+
note = find_note(cfg, event.get("cwd") or "")
|
|
45
|
+
if note is None:
|
|
46
|
+
return None
|
|
47
|
+
try:
|
|
48
|
+
body = note.read_text(errors="replace")[:MAX_BYTES]
|
|
49
|
+
with open(cfg.notes / ".used", "a") as f:
|
|
50
|
+
f.write(str(note) + "\n")
|
|
51
|
+
except OSError:
|
|
52
|
+
return None
|
|
53
|
+
return {
|
|
54
|
+
"systemMessage": f"[context-handoff] loaded {note}; type go to continue",
|
|
55
|
+
"hookSpecificOutput": {
|
|
56
|
+
"hookEventName": "SessionStart",
|
|
57
|
+
"additionalContext": (
|
|
58
|
+
f"A handoff note from the previous session was loaded ({note}). On the owner's first "
|
|
59
|
+
'message, continue from its "Open items and next steps" unless the owner asks for '
|
|
60
|
+
"something else.\n\n" + body
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Read token usage from a Claude Code session transcript (JSON lines)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .prices import Price, lookup
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Turn:
|
|
14
|
+
"""Usage of one model reply."""
|
|
15
|
+
|
|
16
|
+
model: str
|
|
17
|
+
input: int = 0
|
|
18
|
+
cache_write_5m: int = 0
|
|
19
|
+
cache_write_1h: int = 0
|
|
20
|
+
cache_read: int = 0
|
|
21
|
+
output: int = 0
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def context(self) -> int:
|
|
25
|
+
"""Tokens the model read for this reply: what the next turn re-sends."""
|
|
26
|
+
return self.input + self.cache_write_5m + self.cache_write_1h + self.cache_read
|
|
27
|
+
|
|
28
|
+
def cost(self, price: Price) -> float:
|
|
29
|
+
return (
|
|
30
|
+
self.input * price.input
|
|
31
|
+
+ self.cache_write_5m * price.write_5m
|
|
32
|
+
+ self.cache_write_1h * price.write_1h
|
|
33
|
+
+ self.cache_read * price.cache_read
|
|
34
|
+
+ self.output * price.output
|
|
35
|
+
) / 1e6
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Session:
|
|
40
|
+
turns: list[Turn] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def last(self) -> Turn | None:
|
|
44
|
+
return self.turns[-1] if self.turns else None
|
|
45
|
+
|
|
46
|
+
def cost(self, table=None) -> float:
|
|
47
|
+
return sum(t.cost(lookup(t.model, table)) for t in self.turns)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _turn(usage: dict, model: str) -> Turn:
|
|
51
|
+
creation = usage.get("cache_creation") or {}
|
|
52
|
+
w5 = creation.get("ephemeral_5m_input_tokens")
|
|
53
|
+
w1 = creation.get("ephemeral_1h_input_tokens")
|
|
54
|
+
total_write = usage.get("cache_creation_input_tokens") or 0
|
|
55
|
+
if w5 is None and w1 is None:
|
|
56
|
+
# No TTL breakdown: Claude Code writes with the 1-hour TTL, so price it as that.
|
|
57
|
+
w5, w1 = 0, total_write
|
|
58
|
+
return Turn(
|
|
59
|
+
model=model,
|
|
60
|
+
input=usage.get("input_tokens") or 0,
|
|
61
|
+
cache_write_5m=w5 or 0,
|
|
62
|
+
cache_write_1h=w1 or 0,
|
|
63
|
+
cache_read=usage.get("cache_read_input_tokens") or 0,
|
|
64
|
+
output=usage.get("output_tokens") or 0,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def read(path: str | Path) -> Session:
|
|
69
|
+
"""Main-thread Claude replies, one per message id, in order.
|
|
70
|
+
|
|
71
|
+
A reply is written as several transcript lines repeating the same usage,
|
|
72
|
+
so only the last line seen for each message id counts.
|
|
73
|
+
"""
|
|
74
|
+
replies: dict[str, Turn] = {}
|
|
75
|
+
try:
|
|
76
|
+
f = open(path, encoding="utf-8", errors="replace")
|
|
77
|
+
except OSError:
|
|
78
|
+
return Session()
|
|
79
|
+
with f:
|
|
80
|
+
for line in f:
|
|
81
|
+
if '"usage"' not in line or '"assistant"' not in line:
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
rec = json.loads(line)
|
|
85
|
+
except ValueError:
|
|
86
|
+
continue
|
|
87
|
+
if rec.get("type") != "assistant" or rec.get("isSidechain"):
|
|
88
|
+
continue
|
|
89
|
+
msg = rec.get("message") or {}
|
|
90
|
+
usage = msg.get("usage")
|
|
91
|
+
model = msg.get("model") or ""
|
|
92
|
+
if not usage or not model.startswith("claude"):
|
|
93
|
+
continue
|
|
94
|
+
key = msg.get("id") or f"line-{len(replies)}"
|
|
95
|
+
replies.pop(key, None) # keep insertion order at the latest position
|
|
96
|
+
replies[key] = _turn(usage, model)
|
|
97
|
+
return Session(list(replies.values()))
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: context-handoff
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Keep Claude Code sessions cheap: meter cost, write a handoff note, resume after /clear
|
|
5
|
+
Author: Yauhen Bichel
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/YauhenBichel/context-handoff
|
|
8
|
+
Project-URL: Changelog, https://github.com/YauhenBichel/context-handoff/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/YauhenBichel/context-handoff/issues
|
|
10
|
+
Keywords: claude,claude-code,llm,cost,context,handoff
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# context-handoff
|
|
23
|
+
|
|
24
|
+
[](https://github.com/YauhenBichel/context-handoff/actions/workflows/ci.yml)
|
|
25
|
+
[](https://github.com/YauhenBichel/context-handoff/releases)
|
|
26
|
+
[](LICENSE)
|
|
27
|
+
|
|
28
|
+
A Claude Code plugin that keeps long sessions from getting expensive: it shows
|
|
29
|
+
what each turn costs, writes a handoff note when the session grows, and resumes
|
|
30
|
+
from it after `/clear`.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
/plugin marketplace add YauhenBichel/context-handoff
|
|
34
|
+
/plugin install context-handoff@context-handoff
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Python 3.10+ as `python3`, nothing else. Details in [Install](#install).
|
|
38
|
+
|
|
39
|
+
Every Claude Code turn re-sends the whole conversation. While the prompt cache
|
|
40
|
+
is warm that re-read is cheap; after a pause the whole context is written to
|
|
41
|
+
the cache again at up to 2× the input price. A 400K-token session on Claude
|
|
42
|
+
Opus 5.5 pays about $3.20 for one such cold turn, before Claude writes a word.
|
|
43
|
+
Long sessions also get slower and lose focus.
|
|
44
|
+
|
|
45
|
+
context-handoff watches what each turn costs. When a session gets too big or
|
|
46
|
+
too expensive, Claude writes a short handoff note at the end of the current
|
|
47
|
+
task. You run `/clear`, type `go`, and a fresh, small session continues from
|
|
48
|
+
the note.
|
|
49
|
+
|
|
50
|
+
What you see above a prompt once a session is past the warn limit:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
[context-handoff] 182K context, last turn $0.61, session $14.20, a cold turn would cost $1.46
|
|
54
|
+
(claude-opus-5-5 prices). Claude will write a handoff note after this task; then /clear and type go.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## How it works
|
|
58
|
+
|
|
59
|
+
1. **Meter** (`UserPromptSubmit` hook). Before each prompt it reads the
|
|
60
|
+
session transcript and computes, at API list prices:
|
|
61
|
+
- the context the next turn will re-send,
|
|
62
|
+
- what the last turn cost,
|
|
63
|
+
- what the whole session cost,
|
|
64
|
+
- what one cold turn would cost now.
|
|
65
|
+
2. **Auto handoff**. Over a warn limit, it asks Claude (once per session) to
|
|
66
|
+
write a handoff note when the current task is done.
|
|
67
|
+
3. **Block**. Over a block limit, it holds the prompt until you type `handoff`
|
|
68
|
+
or add `#override`.
|
|
69
|
+
4. **Resume** (`SessionStart` hook, after `/clear`). It loads the project's
|
|
70
|
+
newest unused note into the new session, so you type `go` instead of
|
|
71
|
+
pasting a prompt. Each note is loaded once.
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
In Claude Code:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
/plugin marketplace add YauhenBichel/context-handoff
|
|
79
|
+
/plugin install context-handoff@context-handoff
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Or from a terminal:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
claude plugin marketplace add YauhenBichel/context-handoff
|
|
86
|
+
claude plugin install context-handoff@context-handoff
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Restart Claude Code (or start a new session) to load the hooks. The meter
|
|
90
|
+
line appears from the second prompt of a session.
|
|
91
|
+
|
|
92
|
+
Requires Python 3.10 or newer on `PATH` as `python3`. No other dependencies.
|
|
93
|
+
|
|
94
|
+
### Without Claude Code plugins
|
|
95
|
+
|
|
96
|
+
The same code is on PyPI. It gives the `context-handoff` command for reports
|
|
97
|
+
and checks, for example in a shell or on a machine where you don't use the
|
|
98
|
+
plugin:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
pipx install context-handoff # or: uv tool install context-handoff
|
|
102
|
+
context-handoff report 14
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The hooks still come from the plugin above; the package does not install them.
|
|
106
|
+
|
|
107
|
+
Update or remove:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
claude plugin marketplace update context-handoff
|
|
111
|
+
claude plugin update context-handoff@context-handoff
|
|
112
|
+
claude plugin uninstall context-handoff@context-handoff
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Use
|
|
116
|
+
|
|
117
|
+
- **Normal flow**: keep working. When the meter asks, Claude writes the note
|
|
118
|
+
after the current task and tells you. Run `/clear`, then type `go`.
|
|
119
|
+
- **By hand**: `/context-handoff:handoff`, then `/clear` and `go`.
|
|
120
|
+
- **Past a block**: start the prompt with `handoff`, or add `#override`.
|
|
121
|
+
- **Check a session**: `python3 bin/context-handoff check <transcript.jsonl>`.
|
|
122
|
+
- **Report** of the last N days (default 7):
|
|
123
|
+
`python3 bin/context-handoff report 14`.
|
|
124
|
+
|
|
125
|
+
## Settings
|
|
126
|
+
|
|
127
|
+
Defaults, then `~/.claude/context-handoff.json`, then environment variables
|
|
128
|
+
(`CONTEXT_HANDOFF_` + the key in upper case). A limit of 0 turns it off.
|
|
129
|
+
|
|
130
|
+
| Key | Default | Meaning |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| `warn_k` | 150 | context size (thousands of tokens) that triggers a handoff |
|
|
133
|
+
| `block_k` | 400 | context size that blocks prompts |
|
|
134
|
+
| `warn_turn_usd` | 0.50 | cost of the last turn that triggers a handoff |
|
|
135
|
+
| `block_turn_usd` | 3.00 | cost of the last turn that blocks prompts |
|
|
136
|
+
| `warn_session_usd` | 20 | session total that triggers a handoff |
|
|
137
|
+
| `auto_handoff` | true | Claude writes the note itself; false = only a warning |
|
|
138
|
+
| `max_age_min` | 180 | notes older than this are not resumed |
|
|
139
|
+
| `handoff_dir` | `~/.claude/handoffs` | where notes go |
|
|
140
|
+
| `state_dir` | `~/.claude/context-handoff` | log (`meter.jsonl`) and per-session markers |
|
|
141
|
+
| `prices` | see [prices.py](src/context_handoff/prices.py) | `{"model-prefix": [input, output, cache_read]}` per million tokens |
|
|
142
|
+
|
|
143
|
+
Example `~/.claude/context-handoff.json`:
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
{ "warn_turn_usd": 1.0, "warn_session_usd": 50, "prices": { "claude-opus-5-5": [4.0, 20.0, 0.20] } }
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Prices
|
|
150
|
+
|
|
151
|
+
The default table holds Anthropic's first-party API list prices as of November
|
|
152
|
+
2026. Cache writes are priced at 1.25× input (5-minute TTL) or 2× input (1-hour
|
|
153
|
+
TTL). On a Claude subscription you do not pay per token, but the same numbers
|
|
154
|
+
show how fast a session uses your limits. Prices change: override them in the
|
|
155
|
+
config file.
|
|
156
|
+
|
|
157
|
+
## Limits
|
|
158
|
+
|
|
159
|
+
- A plugin cannot run `/clear` for you. That one step stays manual.
|
|
160
|
+
- Notes are matched by the project folder's name, and only those newer than
|
|
161
|
+
`max_age_min` are loaded.
|
|
162
|
+
- The meter reads the transcript, so the first prompt of a session is not
|
|
163
|
+
metered.
|
|
164
|
+
- Costs are estimates from the transcript's usage fields, not your bill.
|
|
165
|
+
|
|
166
|
+
## Privacy
|
|
167
|
+
|
|
168
|
+
Everything runs locally. The plugin reads your transcripts in `~/.claude` and
|
|
169
|
+
writes notes and a log there. Nothing is sent anywhere. Do not put secrets in
|
|
170
|
+
handoff notes.
|
|
171
|
+
|
|
172
|
+
## Development
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
python3 -m unittest discover -s tests
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
context_handoff/__init__.py,sha256=yNmguB0FR0W_PRnGEwLiCq878C16JtzTwjNOsowM4ro,105
|
|
2
|
+
context_handoff/__main__.py,sha256=HAVx6Bdxknoy6a7MFGr-SRfRa8YtwJsF8B0KJjcahnI,1658
|
|
3
|
+
context_handoff/config.py,sha256=xtz6NkHgMSgXM_tQ6kZn8AIIRdn_n_oBR-mfz46xYNc,3007
|
|
4
|
+
context_handoff/meter.py,sha256=Z4jkPElt5QOR6yL1cXx0c-wRRNrRCzY-3jytfMB2YMM,6360
|
|
5
|
+
context_handoff/prices.py,sha256=dERyLRaYIoejXPWfUi9mB4qK6MOhMby6Rg0QgntXdzk,2122
|
|
6
|
+
context_handoff/report.py,sha256=HsPpYJrPaAcW9Fju5qWfLuVDZrYztZtecmyR1QHPWbc,1773
|
|
7
|
+
context_handoff/resume.py,sha256=iYNU28T0aa2uCUhUqciGSQ5HO9D06hQBpz23jzE9TX8,1920
|
|
8
|
+
context_handoff/transcript.py,sha256=n4xHVHjCSxwAPp0pwVmGMlRXqy1buv-Q9NB3w_AZ4B0,3091
|
|
9
|
+
context_handoff-0.1.1.dist-info/licenses/LICENSE,sha256=b5IGCXKe5CTz8uH4O5cBtOC0aVMSlOgoKEC6DOHyOy4,1070
|
|
10
|
+
context_handoff-0.1.1.dist-info/METADATA,sha256=BeJTLVhQxGFvTHuwokA-kYGMTMldDR0cp_KceXrikUU,6904
|
|
11
|
+
context_handoff-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
12
|
+
context_handoff-0.1.1.dist-info/entry_points.txt,sha256=wJyGDDsDpPcM_rNd5DtAz9mi2equgTTxyPTvFGCfdUU,66
|
|
13
|
+
context_handoff-0.1.1.dist-info/top_level.txt,sha256=a7XB-PbU5WJWkEViOFBiIJR4SyEcw6ohWF2sTEtVGzk,16
|
|
14
|
+
context_handoff-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yauhen Bichel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
context_handoff
|