cigar-break 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cigar/__init__.py +3 -0
- cigar/auto.py +70 -0
- cigar/cli.py +128 -0
- cigar/config.py +120 -0
- cigar/defaults.py +100 -0
- cigar/handoff.py +176 -0
- cigar/hook.py +276 -0
- cigar/knee.py +129 -0
- cigar/logs.py +42 -0
- cigar/mcp_server.py +297 -0
- cigar/migrations/0001_init.sql +70 -0
- cigar/migrations/0002_context_window.sql +3 -0
- cigar/report.py +384 -0
- cigar/score.py +187 -0
- cigar/share.py +290 -0
- cigar/signals.py +179 -0
- cigar/status.py +75 -0
- cigar/store.py +359 -0
- cigar/track.py +293 -0
- cigar/transcript.py +84 -0
- cigar_break-0.1.0.dist-info/METADATA +163 -0
- cigar_break-0.1.0.dist-info/RECORD +25 -0
- cigar_break-0.1.0.dist-info/WHEEL +4 -0
- cigar_break-0.1.0.dist-info/entry_points.txt +3 -0
- cigar_break-0.1.0.dist-info/licenses/LICENSE +21 -0
cigar/__init__.py
ADDED
cigar/auto.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Auto mode markers (D-007, D-021, D-026).
|
|
2
|
+
|
|
3
|
+
In `auto` mode, once a handoff is saved for the session, the hook writes
|
|
4
|
+
<CIGAR_HOME>/auto/<session id>.json with state "ready" and the handoff's path.
|
|
5
|
+
The mod in mods/auto.ts reads it at the end of the turn, marks it "fired" and
|
|
6
|
+
runs /clear. With no marker, or one without a handoff path, it never clears.
|
|
7
|
+
Only these markers cross between the two.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
MAX_AGE_SECONDS = 24 * 3600
|
|
17
|
+
FIRED_MAX_AGE_SECONDS = 600 # a clear that fired longer ago than this is not "just now"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def marker_path(home: Path, session_id: str) -> Path:
|
|
21
|
+
safe = "".join(c for c in session_id if c.isalnum() or c in "-_")
|
|
22
|
+
return home / "auto" / f"{safe}.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def mark_ready(home: Path, session_id: str, handoff: str) -> Path:
|
|
26
|
+
path = marker_path(home, session_id)
|
|
27
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
tmp = path.with_suffix(".tmp")
|
|
29
|
+
marker = {"state": "ready", "session": session_id, "handoff": handoff, "ts": time.time()}
|
|
30
|
+
tmp.write_text(json.dumps(marker), encoding="utf-8")
|
|
31
|
+
tmp.replace(path)
|
|
32
|
+
return path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def take_fired(home: Path, max_age: float = FIRED_MAX_AGE_SECONDS) -> str | None:
|
|
36
|
+
"""The handoff path of a clear the mod just fired, removing its marker."""
|
|
37
|
+
folder = home / "auto"
|
|
38
|
+
if not folder.is_dir():
|
|
39
|
+
return None
|
|
40
|
+
found: tuple[float, str] | None = None
|
|
41
|
+
cutoff = time.time() - max_age
|
|
42
|
+
for path in folder.glob("*.json"):
|
|
43
|
+
try:
|
|
44
|
+
marker = json.loads(path.read_text(encoding="utf-8"))
|
|
45
|
+
if marker.get("state") != "fired" or path.stat().st_mtime < cutoff:
|
|
46
|
+
continue
|
|
47
|
+
handoff = marker.get("handoff")
|
|
48
|
+
if isinstance(handoff, str) and handoff:
|
|
49
|
+
stamp = path.stat().st_mtime
|
|
50
|
+
if found is None or stamp > found[0]:
|
|
51
|
+
found = (stamp, handoff)
|
|
52
|
+
path.unlink()
|
|
53
|
+
except (OSError, ValueError, AttributeError):
|
|
54
|
+
continue
|
|
55
|
+
return found[1] if found else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def prune(home: Path, max_age: float = MAX_AGE_SECONDS) -> None:
|
|
59
|
+
"""Remove fired markers and any older than max_age."""
|
|
60
|
+
folder = home / "auto"
|
|
61
|
+
if not folder.is_dir():
|
|
62
|
+
return
|
|
63
|
+
cutoff = time.time() - max_age
|
|
64
|
+
for path in folder.glob("*.json"):
|
|
65
|
+
try:
|
|
66
|
+
fired = json.loads(path.read_text(encoding="utf-8")).get("state") == "fired"
|
|
67
|
+
if fired or path.stat().st_mtime < cutoff:
|
|
68
|
+
path.unlink()
|
|
69
|
+
except (OSError, ValueError, AttributeError):
|
|
70
|
+
path.unlink(missing_ok=True)
|
cigar/cli.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""`cigar` command line: status, report, share and mode (FR-C6)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from cigar import __version__, config, status
|
|
12
|
+
from cigar.store import Store, project_hash
|
|
13
|
+
|
|
14
|
+
MODE_HELP = {
|
|
15
|
+
"suggest": "Cigar nudges the agent and you when the session shows fatigue (default).",
|
|
16
|
+
"observe": "Cigar records signals only, with no nudges.",
|
|
17
|
+
"auto": (
|
|
18
|
+
"After a handoff is saved, Cigar clears the context itself at the end of the turn and "
|
|
19
|
+
"restores the handoff. Needs plugin mods (Claude Code 2.1.287 or newer); tested in the "
|
|
20
|
+
"terminal CLI only."
|
|
21
|
+
),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
26
|
+
cfg = config.load()
|
|
27
|
+
project_dir = args.project or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
|
28
|
+
project = project_hash(str(Path(project_dir)))
|
|
29
|
+
with Store.open(cfg.db_path) as store:
|
|
30
|
+
info = status.collect(store, cfg, args.session, project)
|
|
31
|
+
print(json.dumps(info, indent=2) if args.json else status.render(info))
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_mode(args: argparse.Namespace) -> int:
|
|
36
|
+
cfg = config.load()
|
|
37
|
+
mode = (args.mode or "").strip().lower()
|
|
38
|
+
if not mode:
|
|
39
|
+
print(f"Mode: {cfg.mode}. {MODE_HELP[cfg.mode]}")
|
|
40
|
+
return 0
|
|
41
|
+
if mode not in config.MODES:
|
|
42
|
+
print(f"Unknown mode {mode!r}. Choose one of: {', '.join(config.MODES)}.")
|
|
43
|
+
return 2
|
|
44
|
+
args.mode = mode
|
|
45
|
+
path = config.write_value(cfg.home, "mode", args.mode)
|
|
46
|
+
note = ""
|
|
47
|
+
if os.environ.get("CIGAR_MODE") and os.environ["CIGAR_MODE"] != args.mode:
|
|
48
|
+
note = f" Note: CIGAR_MODE={os.environ['CIGAR_MODE']} is set and overrides it."
|
|
49
|
+
print(f"Mode set to {args.mode} in {path}. {MODE_HELP[args.mode]}{note}")
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
54
|
+
from cigar import report, track
|
|
55
|
+
|
|
56
|
+
cfg = config.load()
|
|
57
|
+
with Store.open(cfg.db_path) as store:
|
|
58
|
+
fits = track.refresh_knees(store, force=True) or []
|
|
59
|
+
compactions = store.compaction_tokens()
|
|
60
|
+
print(report.render_text(fits, compactions))
|
|
61
|
+
if args.html or args.open:
|
|
62
|
+
path = report.write_html(cfg.home, report.render_html(fits, compactions))
|
|
63
|
+
print(f"\nHTML report: {path}")
|
|
64
|
+
if args.open:
|
|
65
|
+
import webbrowser
|
|
66
|
+
|
|
67
|
+
webbrowser.open(path.as_uri())
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_share(args: argparse.Namespace) -> int:
|
|
72
|
+
from cigar import share, track
|
|
73
|
+
|
|
74
|
+
cfg = config.load()
|
|
75
|
+
with Store.open(cfg.db_path) as store:
|
|
76
|
+
fits = track.refresh_knees(store, force=True) or []
|
|
77
|
+
harnesses = track.harnesses(store)
|
|
78
|
+
return share.run(
|
|
79
|
+
cfg,
|
|
80
|
+
fits,
|
|
81
|
+
harnesses,
|
|
82
|
+
model=args.model,
|
|
83
|
+
window=args.window,
|
|
84
|
+
dry_run=args.dry_run,
|
|
85
|
+
yes=args.yes,
|
|
86
|
+
skill=args.skill,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
91
|
+
parser = argparse.ArgumentParser(
|
|
92
|
+
prog="cigar", description="Cigar: take a break before the context rots."
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument("--version", action="version", version=f"cigar {__version__}")
|
|
95
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
96
|
+
|
|
97
|
+
p_status = sub.add_parser("status", help="score, context size, knee, recent signals and mode")
|
|
98
|
+
p_status.add_argument("--session", help="session ID (default: the latest in this project)")
|
|
99
|
+
p_status.add_argument("--project", help="project directory (default: current directory)")
|
|
100
|
+
p_status.add_argument("--json", action="store_true", help="print JSON")
|
|
101
|
+
p_status.set_defaults(func=cmd_status)
|
|
102
|
+
|
|
103
|
+
p_report = sub.add_parser("report", help="your signal rate by context size, per model")
|
|
104
|
+
p_report.add_argument("--html", action="store_true", help="also write the HTML report")
|
|
105
|
+
p_report.add_argument("--open", action="store_true", help="write the HTML and open it")
|
|
106
|
+
p_report.set_defaults(func=cmd_report)
|
|
107
|
+
|
|
108
|
+
p_share = sub.add_parser("share", help="share your anonymous curve (opt-in, shows it first)")
|
|
109
|
+
p_share.add_argument("--dry-run", action="store_true", help="print the payload only")
|
|
110
|
+
p_share.add_argument("--yes", action="store_true", help="skip the confirmation question")
|
|
111
|
+
p_share.add_argument("--model", help="which model's curve (default: the most data)")
|
|
112
|
+
p_share.add_argument("--window", choices=["200k", "1m"], help="which context window")
|
|
113
|
+
p_share.add_argument("--skill", help=argparse.SUPPRESS) # /cigar:share passes its argument
|
|
114
|
+
p_share.set_defaults(func=cmd_share)
|
|
115
|
+
|
|
116
|
+
p_mode = sub.add_parser("mode", help="show or set the mode")
|
|
117
|
+
p_mode.add_argument("mode", nargs="?", help="suggest, observe or auto")
|
|
118
|
+
p_mode.set_defaults(func=cmd_mode)
|
|
119
|
+
return parser
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(argv: list[str] | None = None) -> int:
|
|
123
|
+
args = build_parser().parse_args(argv)
|
|
124
|
+
return int(args.func(args))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
sys.exit(main())
|
cigar/config.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Configuration: ~/.cigar/config.toml, overridden by environment variables.
|
|
2
|
+
|
|
3
|
+
Precedence (highest first): environment, config.toml, defaults.py.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass, field, replace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from cigar import defaults
|
|
14
|
+
|
|
15
|
+
MODES = ("suggest", "observe", "auto")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cigar_home() -> Path:
|
|
19
|
+
return Path(os.environ.get("CIGAR_HOME") or "~/.cigar").expanduser()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Config:
|
|
24
|
+
home: Path
|
|
25
|
+
mode: str = "suggest"
|
|
26
|
+
min_tokens: int = defaults.MIN_TOKENS
|
|
27
|
+
knee_tokens: int | None = None
|
|
28
|
+
cooldown_events: int = defaults.COOLDOWN_EVENTS
|
|
29
|
+
max_nudges: int = defaults.MAX_NUDGES
|
|
30
|
+
check_patterns: tuple[str, ...] = field(default=defaults.CHECK_PATTERNS)
|
|
31
|
+
context_window: str | None = None # "200k" or "1m"; None means detect (D-026)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def db_path(self) -> Path:
|
|
35
|
+
return self.home / "cigar.db"
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def config_path(self) -> Path:
|
|
39
|
+
return self.home / "config.toml"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def log_dir(self) -> Path:
|
|
43
|
+
return self.home / "logs"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_toml(path: Path) -> dict[str, Any]:
|
|
47
|
+
if not path.is_file():
|
|
48
|
+
return {}
|
|
49
|
+
import tomllib
|
|
50
|
+
|
|
51
|
+
with path.open("rb") as f:
|
|
52
|
+
return tomllib.load(f)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _window(*values: Any) -> str | None:
|
|
56
|
+
"""The first value that names a known window ("200k" or "1m")."""
|
|
57
|
+
for value in values:
|
|
58
|
+
label = str(value).strip().lower() if value is not None else ""
|
|
59
|
+
if label in defaults.WINDOWS:
|
|
60
|
+
return label
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _int(*values: Any) -> int | None:
|
|
65
|
+
"""The first value that is set, as an int."""
|
|
66
|
+
for value in values:
|
|
67
|
+
if value is not None and value != "":
|
|
68
|
+
return int(value)
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load(env: dict[str, str] | None = None) -> Config:
|
|
73
|
+
env = dict(os.environ) if env is None else env
|
|
74
|
+
home = Path(env.get("CIGAR_HOME") or "~/.cigar").expanduser()
|
|
75
|
+
cfg = Config(home=home)
|
|
76
|
+
data = _read_toml(cfg.config_path)
|
|
77
|
+
|
|
78
|
+
mode = env.get("CIGAR_MODE") or data.get("mode") or cfg.mode
|
|
79
|
+
if mode not in MODES:
|
|
80
|
+
mode = "suggest"
|
|
81
|
+
min_tokens = _int(env.get("CIGAR_MIN_TOKENS"), data.get("min_tokens"))
|
|
82
|
+
knee = _int(env.get("CIGAR_KNEE_TOKENS"), data.get("knee_tokens"))
|
|
83
|
+
cooldown = _int(data.get("cooldown_events"))
|
|
84
|
+
max_nudges = _int(data.get("max_nudges"))
|
|
85
|
+
patterns = data.get("check_patterns")
|
|
86
|
+
|
|
87
|
+
return replace(
|
|
88
|
+
cfg,
|
|
89
|
+
mode=mode,
|
|
90
|
+
min_tokens=min_tokens if min_tokens is not None else cfg.min_tokens,
|
|
91
|
+
knee_tokens=knee,
|
|
92
|
+
cooldown_events=cooldown if cooldown is not None else cfg.cooldown_events,
|
|
93
|
+
max_nudges=max_nudges if max_nudges is not None else cfg.max_nudges,
|
|
94
|
+
check_patterns=tuple(patterns) if isinstance(patterns, list) else cfg.check_patterns,
|
|
95
|
+
context_window=_window(env.get("CIGAR_CONTEXT_WINDOW"), data.get("context_window")),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _toml_value(value: Any) -> str:
|
|
100
|
+
if isinstance(value, bool):
|
|
101
|
+
return "true" if value else "false"
|
|
102
|
+
if isinstance(value, int | float):
|
|
103
|
+
return str(value)
|
|
104
|
+
if isinstance(value, list | tuple):
|
|
105
|
+
return "[" + ", ".join(_toml_value(v) for v in value) + "]"
|
|
106
|
+
s = str(value).replace("\\", "\\\\").replace('"', '\\"')
|
|
107
|
+
return f'"{s}"'
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def write_value(home: Path, key: str, value: Any) -> Path:
|
|
111
|
+
"""Set one top-level key in config.toml, keeping the other keys."""
|
|
112
|
+
path = home / "config.toml"
|
|
113
|
+
data = _read_toml(path)
|
|
114
|
+
data[key] = value
|
|
115
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
lines = [f"{k} = {_toml_value(v)}" for k, v in data.items() if not isinstance(v, dict)]
|
|
117
|
+
tmp = path.with_suffix(".toml.tmp")
|
|
118
|
+
tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
119
|
+
tmp.replace(path)
|
|
120
|
+
return path
|
cigar/defaults.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Tuning constants. This is the only module where tuning numbers live.
|
|
2
|
+
|
|
3
|
+
Every value here is a starting point. The learned knee (Session 2) replaces
|
|
4
|
+
the default knees, and config.toml or environment variables can override the
|
|
5
|
+
rest (architecture §12). The reasoning for the default knees is in D-017.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
# --- Signals (architecture §6) ---------------------------------------------
|
|
13
|
+
WINDOW_EVENTS = 20
|
|
14
|
+
# Rules (a) and (c) judge behavior only over at least this many events (D-020).
|
|
15
|
+
MIN_WINDOW_EVENTS = 10
|
|
16
|
+
UNDO_LOOKBACK_EVENTS = 50
|
|
17
|
+
|
|
18
|
+
WEIGHTS: dict[str, float] = {
|
|
19
|
+
"failure": 1.0,
|
|
20
|
+
"repeat": 0.8,
|
|
21
|
+
"reread": 0.5,
|
|
22
|
+
"undo": 1.0,
|
|
23
|
+
"check_fail": 0.7,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
READ_TOOLS = frozenset({"Read", "NotebookRead"})
|
|
27
|
+
EDIT_TOOLS = frozenset({"Edit", "MultiEdit", "Write", "NotebookEdit"})
|
|
28
|
+
SHELL_TOOLS = frozenset({"Bash", "PowerShell"})
|
|
29
|
+
|
|
30
|
+
# Shell commands that count as a test, build, lint or type check.
|
|
31
|
+
CHECK_PATTERNS: tuple[str, ...] = (
|
|
32
|
+
r"\b(pytest|py\.test|tox|nox|unittest)\b",
|
|
33
|
+
r"\b(npm|pnpm|yarn|bun)\s+(run\s+)?(test|build|lint|typecheck|type-check|check)\b",
|
|
34
|
+
r"\b(jest|vitest|mocha|playwright\s+test|cypress\s+run)\b",
|
|
35
|
+
r"\b(tsc|eslint|biome|prettier\s+--check)\b",
|
|
36
|
+
r"\b(ruff|flake8|pylint|mypy|pyright|black\s+--check)\b",
|
|
37
|
+
r"\bcargo\s+(test|build|check|clippy)\b",
|
|
38
|
+
r"\bgo\s+(test|build|vet)\b|\bgolangci-lint\b",
|
|
39
|
+
r"\b(make|ninja|cmake\s+--build|bazel\s+(build|test)|gradle\w*|mvn)\b",
|
|
40
|
+
r"\b(rspec|rubocop|phpunit|swift\s+(build|test)|dotnet\s+(build|test))\b",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# --- Score and nudge policy (architecture §6) -------------------------------
|
|
44
|
+
DEFAULT_BASELINE = 0.08
|
|
45
|
+
BASELINE_MIN_EVENTS = 200
|
|
46
|
+
BASELINE_KNEE_FRACTION = 0.4
|
|
47
|
+
BASELINE_CACHE_SECONDS = 3600
|
|
48
|
+
|
|
49
|
+
BEHAVIOR_MARGIN = 0.10 # rule (a): behavior >= baseline + margin, past the knee
|
|
50
|
+
PRESSURE_KNEE = 1.0 # rule (a)
|
|
51
|
+
PRESSURE_HARD = 1.3 # rule (b): well past the knee, whatever the behavior
|
|
52
|
+
DISTRESS_BEHAVIOR = 0.45 # rule (c)
|
|
53
|
+
DISTRESS_PRESSURE = 0.6 # rule (c)
|
|
54
|
+
FATIGUE_PRESSURE_CAP = 1.5
|
|
55
|
+
|
|
56
|
+
MIN_TOKENS = 40_000
|
|
57
|
+
COOLDOWN_EVENTS = 25
|
|
58
|
+
MAX_NUDGES = 3
|
|
59
|
+
|
|
60
|
+
# --- Handoffs ---------------------------------------------------------------
|
|
61
|
+
HANDOFF_INJECT_MAX_CHARS = 8_000
|
|
62
|
+
HANDOFF_MAX_AGE_SECONDS = 24 * 3600
|
|
63
|
+
|
|
64
|
+
# --- Transcript tail reading --------------------------------------------------
|
|
65
|
+
TAIL_BLOCK_BYTES = 64 * 1024
|
|
66
|
+
TAIL_MAX_BYTES = 4 * 1024 * 1024
|
|
67
|
+
|
|
68
|
+
# --- Knees (D-017) -------------------------------------------------------------
|
|
69
|
+
STANDARD_WINDOW = 200_000
|
|
70
|
+
EXTENDED_WINDOW = 1_000_000
|
|
71
|
+
# Window labels stored per session and used in shared curves (D-026).
|
|
72
|
+
WINDOWS: dict[str, int] = {"200k": STANDARD_WINDOW, "1m": EXTENDED_WINDOW}
|
|
73
|
+
|
|
74
|
+
# (pattern on the model ID, knee for a 200k window, knee for a 1M window)
|
|
75
|
+
KNEES: tuple[tuple[str, int, int], ...] = (
|
|
76
|
+
(r"opus", 120_000, 160_000),
|
|
77
|
+
(r"sonnet", 100_000, 140_000),
|
|
78
|
+
(r"haiku", 70_000, 70_000),
|
|
79
|
+
)
|
|
80
|
+
FALLBACK_KNEE_CAP = 120_000
|
|
81
|
+
FALLBACK_KNEE_FRACTION = 0.5
|
|
82
|
+
|
|
83
|
+
# A model ID marked like this runs with a 1M window. Claude Code does not mark
|
|
84
|
+
# transcript model IDs (D-014), so seeing more tokens than the standard window
|
|
85
|
+
# also counts as evidence of an extended window.
|
|
86
|
+
EXTENDED_MARKERS = re.compile(r"\[1m\]|-1m\b|context-1m", re.IGNORECASE)
|
|
87
|
+
|
|
88
|
+
# --- Knee learning (architecture §9, D-029) -------------------------------------
|
|
89
|
+
KNEE_BUCKET_TOKENS = 10_000
|
|
90
|
+
KNEE_MIN_BUCKET_EVENTS = 50 # a bucket counts with this many events...
|
|
91
|
+
KNEE_MIN_BUCKET_SESSIONS = 3 # ...from at least this many sessions
|
|
92
|
+
KNEE_LOW_FRACTION = 0.4 # baseline: buckets holding the lowest 40% of events
|
|
93
|
+
KNEE_BASELINE_FLOOR = 0.02 # so a near-zero baseline doesn't make any blip a knee
|
|
94
|
+
KNEE_RISE = 1.5 # knee: the 3-bucket mean reaches 1.5 x baseline...
|
|
95
|
+
KNEE_RUN_BUCKETS = 3
|
|
96
|
+
KNEE_MEDIUM = (5, 6) # (sessions, counting buckets) for medium confidence
|
|
97
|
+
KNEE_HIGH = (15, 10) # ...and for high
|
|
98
|
+
KNEE_REFRESH_SECONDS = 3600 # SessionStart refits at most this often
|
|
99
|
+
|
|
100
|
+
LOG_RETENTION_DAYS = 7
|
cigar/handoff.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Handoffs: validate, render, truncate for injection, save and load (FR-H1 to FR-H5).
|
|
2
|
+
|
|
3
|
+
`validate`, `render` and `for_injection` are pure. `save` and `load` do I/O.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import datetime as dt
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from cigar import defaults
|
|
14
|
+
|
|
15
|
+
HANDOFF_DIR = Path(".cigar") / "handoffs"
|
|
16
|
+
INJECTION_PREAMBLE = (
|
|
17
|
+
"This session continues earlier work. The handoff written before the context reset follows."
|
|
18
|
+
)
|
|
19
|
+
LIST_FIELDS = ("done", "decisions", "next_steps", "open_questions", "gotchas")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HandoffError(ValueError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Handoff:
|
|
28
|
+
goal: str
|
|
29
|
+
status: str
|
|
30
|
+
done: tuple[str, ...] = ()
|
|
31
|
+
decisions: tuple[str, ...] = ()
|
|
32
|
+
files: tuple[tuple[str, str], ...] = ()
|
|
33
|
+
next_steps: tuple[str, ...] = ()
|
|
34
|
+
open_questions: tuple[str, ...] = ()
|
|
35
|
+
gotchas: tuple[str, ...] = ()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _text(data: dict[str, Any], key: str) -> str:
|
|
39
|
+
value = data.get(key)
|
|
40
|
+
if not isinstance(value, str) or not value.strip():
|
|
41
|
+
raise HandoffError(f"`{key}` is required and must be a non-empty string")
|
|
42
|
+
return value.strip()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _items(data: dict[str, Any], key: str) -> tuple[str, ...]:
|
|
46
|
+
value = data.get(key)
|
|
47
|
+
if value is None:
|
|
48
|
+
return ()
|
|
49
|
+
if isinstance(value, str):
|
|
50
|
+
value = [value]
|
|
51
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
52
|
+
raise HandoffError(f"`{key}` must be a list of strings")
|
|
53
|
+
return tuple(v.strip() for v in value if v.strip())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _split_note(path: str, note: str) -> tuple[str, str]:
|
|
57
|
+
"""Agents often write "path — note" in one string; keep the two apart."""
|
|
58
|
+
if not note and " — " in path:
|
|
59
|
+
path, note = (part.strip() for part in path.split(" — ", 1))
|
|
60
|
+
return path, note
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _files(data: dict[str, Any]) -> tuple[tuple[str, str], ...]:
|
|
64
|
+
value = data.get("files")
|
|
65
|
+
if value is None:
|
|
66
|
+
return ()
|
|
67
|
+
if not isinstance(value, list):
|
|
68
|
+
raise HandoffError("`files` must be a list of {path, note}")
|
|
69
|
+
out = []
|
|
70
|
+
for item in value:
|
|
71
|
+
if isinstance(item, str) and item.strip():
|
|
72
|
+
out.append(_split_note(item.strip(), ""))
|
|
73
|
+
elif isinstance(item, dict) and isinstance(item.get("path"), str) and item["path"].strip():
|
|
74
|
+
note = item.get("note")
|
|
75
|
+
out.append(
|
|
76
|
+
_split_note(item["path"].strip(), note.strip() if isinstance(note, str) else "")
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
raise HandoffError("each entry of `files` needs a `path` string")
|
|
80
|
+
return tuple(out)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def validate(data: Any) -> Handoff:
|
|
84
|
+
if not isinstance(data, dict):
|
|
85
|
+
raise HandoffError("the handoff must be an object")
|
|
86
|
+
fields = {key: _items(data, key) for key in LIST_FIELDS}
|
|
87
|
+
handoff = Handoff(
|
|
88
|
+
goal=_text(data, "goal"), status=_text(data, "status"), files=_files(data), **fields
|
|
89
|
+
)
|
|
90
|
+
if not handoff.next_steps:
|
|
91
|
+
raise HandoffError("`next_steps` needs at least one step (the immediate action first)")
|
|
92
|
+
return handoff
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _bullets(items: tuple[str, ...]) -> str:
|
|
96
|
+
return "\n".join(f"- {item}" for item in items) if items else "- (none)"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def render(handoff: Handoff, project_name: str, when: dt.datetime) -> str:
|
|
100
|
+
files = (
|
|
101
|
+
"\n".join(f"- `{path}` — {note}" if note else f"- `{path}`" for path, note in handoff.files)
|
|
102
|
+
if handoff.files
|
|
103
|
+
else "- (none)"
|
|
104
|
+
)
|
|
105
|
+
steps = "\n".join(f"{i}. {step}" for i, step in enumerate(handoff.next_steps, 1))
|
|
106
|
+
return (
|
|
107
|
+
f"# Cigar handoff · {project_name} · {when.isoformat(timespec='minutes')}\n"
|
|
108
|
+
f"**Goal:** {handoff.goal}\n"
|
|
109
|
+
f"**Status:** {handoff.status}\n\n"
|
|
110
|
+
f"## Done\n{_bullets(handoff.done)}\n\n"
|
|
111
|
+
f"## Decisions\n{_bullets(handoff.decisions)}\n\n"
|
|
112
|
+
f"## Files (path — note)\n{files}\n\n"
|
|
113
|
+
f"## Next steps\n{steps}\n\n"
|
|
114
|
+
f"## Open questions\n{_bullets(handoff.open_questions)}\n\n"
|
|
115
|
+
f"## Gotchas\n{_bullets(handoff.gotchas)}\n"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def for_injection(
|
|
120
|
+
markdown: str, rel_path: str, limit: int = defaults.HANDOFF_INJECT_MAX_CHARS
|
|
121
|
+
) -> str:
|
|
122
|
+
"""The text injected after a reset: under `limit` chars (FR-H5).
|
|
123
|
+
|
|
124
|
+
A long handoff is cut to its goal, status and next steps, followed by the
|
|
125
|
+
path to the full version.
|
|
126
|
+
"""
|
|
127
|
+
full = f"{INJECTION_PREAMBLE}\n\n{markdown.strip()}"
|
|
128
|
+
if len(full) <= limit:
|
|
129
|
+
return full
|
|
130
|
+
sections = markdown.strip().split("\n## ")
|
|
131
|
+
head = sections[0]
|
|
132
|
+
steps = next((s for s in sections[1:] if s.startswith("Next steps")), "")
|
|
133
|
+
tail = f"\n\n(Cut to fit. The full handoff is at {rel_path}.)"
|
|
134
|
+
body = f"{INJECTION_PREAMBLE}\n\n{head}\n\n## {steps}".rstrip()
|
|
135
|
+
room = limit - len(tail)
|
|
136
|
+
if len(body) > room:
|
|
137
|
+
body = body[: max(0, room - 1)].rstrip() + "…"
|
|
138
|
+
return body + tail
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _ensure_dir(project_dir: Path) -> Path:
|
|
142
|
+
root = project_dir / ".cigar"
|
|
143
|
+
target = project_dir / HANDOFF_DIR
|
|
144
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
ignore = root / ".gitignore"
|
|
146
|
+
if not ignore.exists():
|
|
147
|
+
ignore.write_text("*\n", encoding="utf-8")
|
|
148
|
+
return target
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def save(
|
|
152
|
+
project_dir: Path, markdown: str, session_id: str | None, when: dt.datetime
|
|
153
|
+
) -> tuple[Path, str]:
|
|
154
|
+
"""Write the handoff file. Returns (absolute path, path relative to the project)."""
|
|
155
|
+
target = _ensure_dir(project_dir)
|
|
156
|
+
stem = f"{when:%Y-%m-%dT%H%M}-{(session_id or 'nosession')[:4]}"
|
|
157
|
+
path = target / f"{stem}.md"
|
|
158
|
+
n = 2
|
|
159
|
+
while path.exists():
|
|
160
|
+
path = target / f"{stem}-{n}.md"
|
|
161
|
+
n += 1
|
|
162
|
+
tmp = path.with_suffix(".md.tmp")
|
|
163
|
+
tmp.write_text(markdown, encoding="utf-8")
|
|
164
|
+
tmp.replace(path)
|
|
165
|
+
return path, path.relative_to(project_dir).as_posix()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def load(project_dir: Path, rel_path: str) -> str | None:
|
|
169
|
+
path = (project_dir / rel_path).resolve()
|
|
170
|
+
handoffs = (project_dir / HANDOFF_DIR).resolve()
|
|
171
|
+
if handoffs not in path.parents:
|
|
172
|
+
return None
|
|
173
|
+
try:
|
|
174
|
+
return path.read_text(encoding="utf-8")
|
|
175
|
+
except OSError:
|
|
176
|
+
return None
|