methodproof 0.8.3__tar.gz → 0.8.5__tar.gz

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.
Files changed (90) hide show
  1. {methodproof-0.8.3 → methodproof-0.8.5}/PKG-INFO +4 -4
  2. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/__init__.py +1 -1
  3. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/agents/watcher.py +9 -2
  4. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/claude_code.py +58 -9
  5. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/claude_code.sh +65 -4
  6. methodproof-0.8.5/methodproof/hooks/model_cache.py +164 -0
  7. {methodproof-0.8.3 → methodproof-0.8.5}/pyproject.toml +5 -5
  8. methodproof-0.8.5/tests/test_model_cache.py +278 -0
  9. methodproof-0.8.5/tests/test_watcher_ignore.py +63 -0
  10. {methodproof-0.8.3 → methodproof-0.8.5}/uv.lock +155 -954
  11. {methodproof-0.8.3 → methodproof-0.8.5}/.github/workflows/ci.yml +0 -0
  12. {methodproof-0.8.3 → methodproof-0.8.5}/.gitignore +0 -0
  13. {methodproof-0.8.3 → methodproof-0.8.5}/CHANGELOG.md +0 -0
  14. {methodproof-0.8.3 → methodproof-0.8.5}/LICENSE +0 -0
  15. {methodproof-0.8.3 → methodproof-0.8.5}/README.md +0 -0
  16. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/__main__.py +0 -0
  17. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/_daemon.py +0 -0
  18. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/agents/__init__.py +0 -0
  19. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/agents/base.py +0 -0
  20. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/agents/music.py +0 -0
  21. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/agents/terminal.py +0 -0
  22. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/analysis.py +0 -0
  23. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/binding.py +0 -0
  24. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/bip39.py +0 -0
  25. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/bridge.py +0 -0
  26. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/cli.py +0 -0
  27. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/config.py +0 -0
  28. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/crypto.py +0 -0
  29. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/e2e.py +0 -0
  30. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/graph.py +0 -0
  31. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hook.py +0 -0
  32. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/__init__.py +0 -0
  33. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/cline_hook.sh +0 -0
  34. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/codex_hook.sh +0 -0
  35. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/gemini_hook.sh +0 -0
  36. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/install.py +0 -0
  37. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/kiro_hook.sh +0 -0
  38. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/mcp_register.py +0 -0
  39. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/openclaw/HOOK.md +0 -0
  40. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/openclaw/handler.ts +0 -0
  41. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/openclaw_install.py +0 -0
  42. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/opencode_plugin.js +0 -0
  43. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/hooks/wrappers.py +0 -0
  44. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/integrity.py +0 -0
  45. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/kdf.py +0 -0
  46. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/keychain.py +0 -0
  47. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/live.py +0 -0
  48. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/lock.py +0 -0
  49. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/mcp.py +0 -0
  50. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/migrate_db.py +0 -0
  51. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/proxy.py +0 -0
  52. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/proxy_daemon.py +0 -0
  53. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/repos.py +0 -0
  54. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/skills/methodproof/SKILL.md +0 -0
  55. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/store.py +0 -0
  56. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/sync.py +0 -0
  57. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/__init__.py +0 -0
  58. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/consent.py +0 -0
  59. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/init.py +0 -0
  60. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/log.py +0 -0
  61. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/login_success.py +0 -0
  62. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/review.py +0 -0
  63. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/start.py +0 -0
  64. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/status.py +0 -0
  65. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/tui/theme.py +0 -0
  66. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/viewer.py +0 -0
  67. {methodproof-0.8.3 → methodproof-0.8.5}/methodproof/wordlist.py +0 -0
  68. {methodproof-0.8.3 → methodproof-0.8.5}/test_windows_compat.py +0 -0
  69. {methodproof-0.8.3 → methodproof-0.8.5}/tests/__init__.py +0 -0
  70. {methodproof-0.8.3 → methodproof-0.8.5}/tests/conftest.py +0 -0
  71. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_analysis.py +0 -0
  72. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_cli_auth.py +0 -0
  73. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_cli_config.py +0 -0
  74. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_cli_helpers.py +0 -0
  75. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_cli_session.py +0 -0
  76. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_cli_share.py +0 -0
  77. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_cli_start.py +0 -0
  78. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_cli_update.py +0 -0
  79. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_e2e_integration.py +0 -0
  80. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_graph.py +0 -0
  81. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_hooks.py +0 -0
  82. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_live.py +0 -0
  83. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_openclaw_hooks.py +0 -0
  84. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_profiles.py +0 -0
  85. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_repos.py +0 -0
  86. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_security.py +0 -0
  87. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_store.py +0 -0
  88. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_sync.py +0 -0
  89. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_viewer.py +0 -0
  90. {methodproof-0.8.3 → methodproof-0.8.5}/tests/test_wrappers.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: methodproof
3
- Version: 0.8.3
3
+ Version: 0.8.5
4
4
  Summary: See how you code. Capture and visualize your engineering process.
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
7
- Requires-Python: >=3.11
8
- Requires-Dist: cryptography>=43.0
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: cryptography>=46.0.7
9
9
  Requires-Dist: keyring>=25.0
10
10
  Requires-Dist: rich>=13.7
11
11
  Requires-Dist: sqlcipher3>=0.6
@@ -13,7 +13,7 @@ Requires-Dist: textual>=0.59
13
13
  Requires-Dist: watchdog>=4.0
14
14
  Requires-Dist: websocket-client>=1.7
15
15
  Provides-Extra: proxy
16
- Requires-Dist: mitmproxy>=10.0; extra == 'proxy'
16
+ Requires-Dist: mitmproxy>=12.2.2; extra == 'proxy'
17
17
  Description-Content-Type: text/markdown
18
18
 
19
19
  <p align="center">
@@ -1,3 +1,3 @@
1
1
  """MethodProof — see how you code."""
2
2
 
3
- __version__ = "0.8.1"
3
+ __version__ = "0.8.2"
@@ -40,8 +40,15 @@ IGNORE_PATTERNS = re.compile(
40
40
  r"|\.build/|DerivedData/|Pods/"
41
41
  # Build output / artifacts
42
42
  r"|dist/|build/|\.output/"
43
- # Logs and locks
44
- r"|\.lock$|\.log$)"
43
+ # Logs and locks — runtime log output, never engineering source.
44
+ # ``/logs/`` excludes any file under a ``logs/`` directory regardless
45
+ # of extension. The prior ``\.log$`` check was too narrow — session
46
+ # 8c21 had 15,269 file_edit events captured on
47
+ # ``methodproof-platform/logs/methodproof-platform.jsonl`` (the
48
+ # platform's own runtime log, NOT ``.log`` extension) which polluted
49
+ # both the thread and step distributions. Any project with a
50
+ # ``logs/`` subdirectory for runtime output inherits the exclusion.
51
+ r"|/logs/|\.lock$|\.log$)"
45
52
  )
46
53
 
47
54
 
@@ -16,6 +16,22 @@ except ImportError:
16
16
  analyze_prompt = lambda _: {}
17
17
  compose_summary = lambda _: ""
18
18
 
19
+ try:
20
+ from methodproof.hooks import model_cache
21
+ except ImportError:
22
+ model_cache = None # type: ignore[assignment]
23
+
24
+
25
+ def _current_model(session_id: str) -> str | None:
26
+ """Return the most recently-seen model for this Claude session, from
27
+ the per-session cache. None if cache unavailable or no prior update."""
28
+ if model_cache is None or not session_id:
29
+ return None
30
+ try:
31
+ return model_cache.get_model(session_id)
32
+ except Exception:
33
+ return None
34
+
19
35
  def _extract_result_text(response) -> str:
20
36
  """Extract plain text from tool_response regardless of shape.
21
37
 
@@ -92,25 +108,34 @@ def _tool_input_preview(d: dict) -> str:
92
108
  return json.dumps(inp)[:300] if inp else ""
93
109
 
94
110
 
111
+ def _with_model(meta: dict, d: dict) -> dict:
112
+ """Attach the session's currently-cached model to a metadata dict.
113
+ No-op when session_id is missing or cache has no entry."""
114
+ model = _current_model(d.get("session_id") or "")
115
+ if model:
116
+ meta["model"] = model
117
+ return meta
118
+
119
+
95
120
  _META_EXTRACTORS = {
96
- "UserPromptSubmit": lambda d: {
121
+ "UserPromptSubmit": lambda d: _with_model({
97
122
  "tool": _TOOL,
98
123
  "prompt_text": d.get("prompt") or "",
99
124
  "prompt_preview": _build_prompt_meta(d.get("prompt") or "").get("prompt_summary", ""),
100
125
  "prompt_length": len(d.get("prompt") or ""),
101
- },
102
- "PreToolUse": lambda d: {
126
+ }, d),
127
+ "PreToolUse": lambda d: _with_model({
103
128
  "tool": _TOOL, "tool_name": d.get("tool_name", "unknown"),
104
129
  "tool_input": d.get("tool_input") or {},
105
130
  "tool_input_preview": _tool_input_preview(d),
106
- },
107
- "PostToolUse": lambda d: {
131
+ }, d),
132
+ "PostToolUse": lambda d: _with_model({
108
133
  "tool": _TOOL, "tool_name": d.get("tool_name", "unknown"), "success": True,
109
134
  "tool_input": d.get("tool_input") or {},
110
135
  "tool_response": d.get("tool_response") or {},
111
136
  "tool_input_preview": _tool_input_preview(d),
112
137
  "result_preview": _extract_result_text(d.get("tool_response")),
113
- },
138
+ }, d),
114
139
  "PostToolUseFailure": lambda d: {
115
140
  "tool": _TOOL, "tool_name": d.get("tool_name", "unknown"),
116
141
  "success": False, "is_interrupt": d.get("is_interrupt", False),
@@ -124,9 +149,9 @@ _META_EXTRACTORS = {
124
149
  },
125
150
  "TaskCreated": lambda d: {"tool": _TOOL, "task_id": d.get("task_id", ""), "subject": d.get("task_subject", "")},
126
151
  "TaskCompleted": lambda d: {"tool": _TOOL, "task_id": d.get("task_id", "")},
127
- "SessionStart": lambda d: {"tool": _TOOL, "session_id": d.get("session_id", ""), "cwd": d.get("cwd", "")},
152
+ "SessionStart": lambda d: _with_model({"tool": _TOOL, "session_id": d.get("session_id", ""), "cwd": d.get("cwd", "")}, d),
128
153
  "SessionEnd": lambda d: {"tool": _TOOL, "session_id": d.get("session_id", "")},
129
- "Stop": lambda d: {"tool": _TOOL},
154
+ "Stop": lambda d: _with_model({"tool": _TOOL}, d),
130
155
  "StopFailure": lambda d: {"tool": _TOOL, "error": d.get("error", "")},
131
156
  "CwdChanged": lambda d: {
132
157
  "tool": _TOOL, "cwd": d.get("cwd", ""),
@@ -175,6 +200,24 @@ def main() -> None:
175
200
  return
176
201
 
177
202
  event = data.get("hook_event_name", "unknown")
203
+ session_id = data.get("session_id") or ""
204
+ transcript_path = data.get("transcript_path", "")
205
+
206
+ # Refresh the per-session model cache before running the metadata extractor
207
+ # so `_with_model` sees the freshest value. We include PreToolUse because
208
+ # the first turn's UserPromptSubmit fires before any assistant message is
209
+ # in the transcript — without a PreToolUse refresh, every tool event in
210
+ # that first turn lands with no `model` attribution. PostToolUse is left
211
+ # out: once PreToolUse has refreshed, the cache is warm for the rest of
212
+ # the turn, and PostToolUse would duplicate the transcript read.
213
+ if model_cache is not None and transcript_path and session_id and event in (
214
+ "UserPromptSubmit", "SessionStart", "Stop", "PreToolUse",
215
+ ):
216
+ try:
217
+ model_cache.update_from_transcript(session_id, transcript_path)
218
+ except Exception:
219
+ pass # Cache is best-effort; hook must never raise.
220
+
178
221
  etype = _TYPE_MAP.get(event, "claude_code_event")
179
222
  extractor = _META_EXTRACTORS.get(event)
180
223
  meta = extractor(data) if extractor else {"tool": _TOOL, "event": event}
@@ -183,7 +226,6 @@ def main() -> None:
183
226
  events_out = [{"type": etype, "timestamp": ts, "metadata": meta}]
184
227
 
185
228
  # On Stop, grep transcript for recap (journal mode only)
186
- transcript_path = data.get("transcript_path", "")
187
229
  if event == "Stop" and transcript_path:
188
230
  recap = _extract_recap(transcript_path)
189
231
  if recap:
@@ -193,6 +235,13 @@ def main() -> None:
193
235
  "metadata": {"tool": _TOOL, "recap": recap[:2000]},
194
236
  })
195
237
 
238
+ # Clean up the cache entry on session end so the file stays bounded.
239
+ if model_cache is not None and event == "SessionEnd" and session_id:
240
+ try:
241
+ model_cache.clear_session(session_id)
242
+ except Exception:
243
+ pass
244
+
196
245
  payload = json.dumps({"events": events_out}).encode()
197
246
  req = urllib.request.Request(
198
247
  "http://localhost:9877/events", data=payload,
@@ -8,6 +8,39 @@
8
8
 
9
9
  INPUT=$(cat)
10
10
 
11
+ # Model cache: per-Claude-session model attribution.
12
+ # The transcript JSONL is the only place Claude Code surfaces the active
13
+ # model. Re-reading it on every PreToolUse is too expensive, so we refresh
14
+ # a cache at the cheap once-per-turn waypoints (SessionStart / Stop — and
15
+ # UserPromptSubmit which delegates to the Python hook that updates it too)
16
+ # and read it via a cheap jq lookup on tool events.
17
+ _MP_MODEL_CACHE="${HOME}/.methodproof/hook_state/models.json"
18
+
19
+ # Read the current model for a session. Fast path — no Python subprocess.
20
+ _mp_read_model() {
21
+ local sess="$1"
22
+ [ -z "$sess" ] || [ ! -f "$_MP_MODEL_CACHE" ] && return
23
+ command -v jq >/dev/null 2>&1 || return
24
+ jq -r --arg s "$sess" '.[$s].model // empty' "$_MP_MODEL_CACHE" 2>/dev/null
25
+ }
26
+
27
+ # Refresh the cache by shelling out to the Python module (handles JSON
28
+ # safely + atomic write). Rare — called on SessionStart / Stop only.
29
+ _mp_update_model() {
30
+ local sess="$1" transcript="$2"
31
+ [ -z "$sess" ] || [ -z "$transcript" ] && return
32
+ command -v python3 >/dev/null 2>&1 || return
33
+ python3 -m methodproof.hooks.model_cache update "$sess" "$transcript" \
34
+ >/dev/null 2>&1 || true
35
+ }
36
+
37
+ _mp_clear_model() {
38
+ local sess="$1"
39
+ [ -z "$sess" ] && return
40
+ command -v python3 >/dev/null 2>&1 || return
41
+ python3 -m methodproof.hooks.model_cache clear "$sess" >/dev/null 2>&1 || true
42
+ }
43
+
11
44
  if command -v jq >/dev/null 2>&1; then
12
45
  EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // "unknown"' 2>/dev/null || echo "unknown")
13
46
  else
@@ -25,6 +58,24 @@ fi
25
58
 
26
59
  # Build event JSON — use jq if available, else minimal Python
27
60
  if command -v jq >/dev/null 2>&1; then
61
+ # Pull session + transcript once — cache ops + model attribution use both.
62
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null)
63
+ TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null)
64
+
65
+ # Refresh model cache at once-per-turn waypoints. Cheap tool events
66
+ # (PreToolUse / PostToolUse) read the cache without touching the
67
+ # transcript.
68
+ case "$EVENT" in
69
+ SessionStart|Stop)
70
+ _mp_update_model "$SESSION_ID" "$TRANSCRIPT"
71
+ ;;
72
+ SessionEnd)
73
+ _mp_clear_model "$SESSION_ID"
74
+ ;;
75
+ esac
76
+
77
+ MP_MODEL=$(_mp_read_model "$SESSION_ID")
78
+
28
79
  case "$EVENT" in
29
80
  UserPromptSubmit)
30
81
  # Delegate to Python for structural analysis (shell can't do regex classification)
@@ -37,9 +88,10 @@ if command -v jq >/dev/null 2>&1; then
37
88
  ;;
38
89
  PreToolUse)
39
90
  TYPE="tool_call"
40
- META=$(echo "$INPUT" | jq -c '{
91
+ META=$(echo "$INPUT" | jq -c --arg model "$MP_MODEL" '{
41
92
  tool: (.tool_name // "unknown"),
42
93
  tool_use_id: (.tool_use_id // ""),
94
+ model: (if $model == "" then null else $model end),
43
95
  tool_input: (.tool_input // {}),
44
96
  tool_input_preview: (
45
97
  (.tool_input // {}) as $ti |
@@ -58,10 +110,11 @@ if command -v jq >/dev/null 2>&1; then
58
110
  ;;
59
111
  PostToolUse)
60
112
  TYPE="tool_result"
61
- META=$(echo "$INPUT" | jq -c '{
113
+ META=$(echo "$INPUT" | jq -c --arg model "$MP_MODEL" '{
62
114
  tool: (.tool_name // "unknown"),
63
115
  tool_use_id: (.tool_use_id // ""),
64
116
  success: true,
117
+ model: (if $model == "" then null else $model end),
65
118
  tool_input: (.tool_input // {}),
66
119
  tool_response: (.tool_response // {}),
67
120
  tool_input_preview: (
@@ -112,7 +165,11 @@ if command -v jq >/dev/null 2>&1; then
112
165
  ;;
113
166
  SessionStart)
114
167
  TYPE="claude_session_start"
115
- META=$(echo "$INPUT" | jq -c '{claude_session_id: (.session_id // ""), cwd: (.cwd // "")}' 2>/dev/null || echo '{}')
168
+ META=$(echo "$INPUT" | jq -c --arg model "$MP_MODEL" '{
169
+ claude_session_id: (.session_id // ""),
170
+ cwd: (.cwd // ""),
171
+ model: (if $model == "" then null else $model end)
172
+ }' 2>/dev/null || echo '{}')
116
173
  ;;
117
174
  PostToolUseFailure)
118
175
  TYPE="tool_failure"
@@ -124,7 +181,11 @@ if command -v jq >/dev/null 2>&1; then
124
181
  ;;
125
182
  Stop)
126
183
  TYPE="agent_turn_end"
127
- META='{"tool":"claude_code"}'
184
+ if [ -n "$MP_MODEL" ]; then
185
+ META="{\"tool\":\"claude_code\",\"model\":\"$MP_MODEL\"}"
186
+ else
187
+ META='{"tool":"claude_code"}'
188
+ fi
128
189
  # Extract recap from transcript if available (journal mode)
129
190
  TRANSCRIPT=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null)
130
191
  if [ -n "$TRANSCRIPT" ] && [ -f "$TRANSCRIPT" ]; then
@@ -0,0 +1,164 @@
1
+ """Per-session model cache for Claude Code capture.
2
+
3
+ Claude Code doesn't pass the active model in every hook payload — only the
4
+ transcript JSONL carries it. Re-reading the transcript on every PreToolUse
5
+ would add tens of ms to each hook invocation. This module keeps a tiny
6
+ JSON cache at ``~/.methodproof/hook_state/models.json`` mapping Claude
7
+ session_id → (model, updated_at), refreshed once per turn at the cheap
8
+ waypoints (``SessionStart``, ``UserPromptSubmit``, ``Stop``) and read
9
+ cheaply on every tool event.
10
+
11
+ Atomic writes via ``tempfile.NamedTemporaryFile`` + ``os.replace`` so
12
+ concurrent hook invocations never corrupt the file.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import pathlib
20
+ import tempfile
21
+ import time
22
+
23
+
24
+ CACHE_PATH = pathlib.Path.home() / ".methodproof" / "hook_state" / "models.json"
25
+ # How far back to scan a transcript for the last assistant message's model.
26
+ # Transcripts are JSONL append-only, so tail is all we need. 200 lines covers
27
+ # a typical turn plus headroom; we're not trying to reconstruct history.
28
+ _TAIL_BYTES = 64 * 1024
29
+
30
+
31
+ def _load() -> dict:
32
+ try:
33
+ with CACHE_PATH.open("r") as f:
34
+ data = json.load(f)
35
+ return data if isinstance(data, dict) else {}
36
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
37
+ return {}
38
+
39
+
40
+ def _save(data: dict) -> None:
41
+ CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
42
+ # Atomic write — write to a tmp file in the same dir, rename over.
43
+ with tempfile.NamedTemporaryFile(
44
+ mode="w", dir=str(CACHE_PATH.parent), delete=False, suffix=".json",
45
+ ) as tmp:
46
+ json.dump(data, tmp)
47
+ tmp_path = tmp.name
48
+ os.replace(tmp_path, CACHE_PATH)
49
+
50
+
51
+ def _extract_last_model(transcript_path: str) -> str | None:
52
+ """Read the tail of a transcript JSONL and return the most recent
53
+ ``model`` field from an assistant message. ``None`` if the transcript
54
+ is missing, unreadable, or contains no model annotation.
55
+ """
56
+ path = pathlib.Path(transcript_path)
57
+ if not path.is_file():
58
+ return None
59
+ try:
60
+ size = path.stat().st_size
61
+ with path.open("rb") as f:
62
+ if size > _TAIL_BYTES:
63
+ f.seek(size - _TAIL_BYTES)
64
+ # Drop partial first line after a seek
65
+ f.readline()
66
+ blob = f.read().decode("utf-8", errors="replace")
67
+ except OSError:
68
+ return None
69
+
70
+ last_model: str | None = None
71
+ for line in blob.splitlines():
72
+ line = line.strip()
73
+ if not line or not line.startswith("{"):
74
+ continue
75
+ try:
76
+ rec = json.loads(line)
77
+ except json.JSONDecodeError:
78
+ continue
79
+ if not isinstance(rec, dict):
80
+ continue
81
+ # Claude Code transcript shape: top-level "model" on assistant messages.
82
+ model = rec.get("model")
83
+ if isinstance(model, str) and model:
84
+ last_model = model
85
+ return last_model
86
+
87
+
88
+ def update_from_transcript(session_id: str, transcript_path: str) -> str | None:
89
+ """Read the transcript tail, extract the most recent model, and persist
90
+ it in the cache keyed by ``session_id``. Returns the model string (or
91
+ ``None`` if extraction failed — cache untouched in that case).
92
+ """
93
+ if not session_id or not transcript_path:
94
+ return None
95
+ model = _extract_last_model(transcript_path)
96
+ if model is None:
97
+ return None
98
+ try:
99
+ data = _load()
100
+ data[session_id] = {"model": model, "updated_at": time.time()}
101
+ _save(data)
102
+ except OSError:
103
+ # Cache is best-effort. A write failure must not break the hook.
104
+ return model
105
+ return model
106
+
107
+
108
+ def get_model(session_id: str) -> str | None:
109
+ """Return the cached model for ``session_id``, or ``None`` if no cache
110
+ entry exists. Never raises — the cache is best-effort."""
111
+ if not session_id:
112
+ return None
113
+ try:
114
+ data = _load()
115
+ except OSError:
116
+ return None
117
+ entry = data.get(session_id)
118
+ if not isinstance(entry, dict):
119
+ return None
120
+ model = entry.get("model")
121
+ return model if isinstance(model, str) and model else None
122
+
123
+
124
+ def clear_session(session_id: str) -> None:
125
+ """Remove a session's cache entry. Called on ``SessionEnd`` so cache
126
+ size stays bounded over time."""
127
+ if not session_id:
128
+ return
129
+ try:
130
+ data = _load()
131
+ if session_id in data:
132
+ del data[session_id]
133
+ _save(data)
134
+ except OSError:
135
+ return
136
+
137
+
138
+ # CLI entry so the shell hook can do `python3 -m methodproof.hooks.model_cache ...`
139
+ # on rare events (SessionStart / Stop / SessionEnd). The hot read path in shell
140
+ # uses jq directly on the cache file — no Python subprocess needed.
141
+ def _main() -> int:
142
+ import sys
143
+ args = sys.argv[1:]
144
+ if len(args) < 1:
145
+ return 1
146
+ cmd = args[0]
147
+ if cmd == "update" and len(args) == 3:
148
+ model = update_from_transcript(args[1], args[2])
149
+ if model:
150
+ print(model)
151
+ return 0
152
+ if cmd == "get" and len(args) == 2:
153
+ model = get_model(args[1])
154
+ if model:
155
+ print(model)
156
+ return 0
157
+ if cmd == "clear" and len(args) == 2:
158
+ clear_session(args[1])
159
+ return 0
160
+ return 1
161
+
162
+
163
+ if __name__ == "__main__":
164
+ raise SystemExit(_main())
@@ -1,17 +1,17 @@
1
1
  [project]
2
2
  name = "methodproof"
3
- version = "0.8.3"
3
+ version = "0.8.5"
4
4
  description = "See how you code. Capture and visualize your engineering process."
5
- requires-python = ">=3.11"
6
- dependencies = ["watchdog>=4.0", "websocket-client>=1.7", "cryptography>=43.0", "keyring>=25.0", "textual>=0.59", "rich>=13.7", "sqlcipher3>=0.6"]
5
+ requires-python = ">=3.12"
6
+ dependencies = ["watchdog>=4.0", "websocket-client>=1.7", "cryptography>=46.0.7", "keyring>=25.0", "textual>=0.59", "rich>=13.7", "sqlcipher3>=0.6"]
7
7
  license = "Apache-2.0"
8
8
  readme = "README.md"
9
9
 
10
10
  [project.optional-dependencies]
11
- proxy = ["mitmproxy>=10.0"]
11
+ proxy = ["mitmproxy>=12.2.2"]
12
12
 
13
13
  [dependency-groups]
14
- dev = ["pytest>=8.0", "pytest-cov>=5.0"]
14
+ dev = ["pytest>=9.0.3", "pytest-cov>=5.0"]
15
15
 
16
16
  [project.scripts]
17
17
  methodproof = "methodproof.cli:main"