methodproof 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.
- methodproof/__init__.py +3 -0
- methodproof/__main__.py +4 -0
- methodproof/agents/__init__.py +0 -0
- methodproof/agents/base.py +136 -0
- methodproof/agents/music.py +105 -0
- methodproof/agents/terminal.py +111 -0
- methodproof/agents/watcher.py +170 -0
- methodproof/bridge.py +86 -0
- methodproof/cli.py +620 -0
- methodproof/config.py +79 -0
- methodproof/crypto.py +46 -0
- methodproof/graph.py +163 -0
- methodproof/hook.py +91 -0
- methodproof/hooks/__init__.py +0 -0
- methodproof/hooks/claude_code.py +63 -0
- methodproof/hooks/claude_code.sh +75 -0
- methodproof/hooks/install.py +87 -0
- methodproof/hooks/openclaw/HOOK.md +15 -0
- methodproof/hooks/openclaw/handler.ts +82 -0
- methodproof/hooks/openclaw_install.py +80 -0
- methodproof/hooks/wrappers.py +113 -0
- methodproof/integrity.py +71 -0
- methodproof/live.py +79 -0
- methodproof/mcp.py +128 -0
- methodproof/repos.py +25 -0
- methodproof/skills/methodproof/SKILL.md +76 -0
- methodproof/store.py +272 -0
- methodproof/sync.py +127 -0
- methodproof/viewer.py +239 -0
- methodproof-0.1.1.dist-info/METADATA +177 -0
- methodproof-0.1.1.dist-info/RECORD +34 -0
- methodproof-0.1.1.dist-info/WHEEL +4 -0
- methodproof-0.1.1.dist-info/entry_points.txt +2 -0
- methodproof-0.1.1.dist-info/licenses/LICENSE +190 -0
methodproof/__init__.py
ADDED
methodproof/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Shared telemetry primitives — one log, one emit, one flush."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from methodproof import store
|
|
11
|
+
|
|
12
|
+
_session_id = ""
|
|
13
|
+
_initialized = False
|
|
14
|
+
_e2e_key: bytes | None = None
|
|
15
|
+
_capture: dict[str, bool] = {}
|
|
16
|
+
_live_mode = False
|
|
17
|
+
_lock = threading.Lock()
|
|
18
|
+
_buffer: list[dict[str, Any]] = []
|
|
19
|
+
_FLUSH_SIZE = 50
|
|
20
|
+
_MAX_RETRIES = 3
|
|
21
|
+
_prev_hash = "genesis"
|
|
22
|
+
|
|
23
|
+
# Maps event types to the capture category that gates them
|
|
24
|
+
_EVENT_GATES: dict[str, str] = {
|
|
25
|
+
"terminal_cmd": "terminal_commands",
|
|
26
|
+
"test_run": "test_results",
|
|
27
|
+
"file_create": "file_changes",
|
|
28
|
+
"file_edit": "file_changes",
|
|
29
|
+
"file_delete": "file_changes",
|
|
30
|
+
"git_commit": "git_commits",
|
|
31
|
+
"llm_prompt": "ai_prompts",
|
|
32
|
+
"agent_prompt": "ai_prompts",
|
|
33
|
+
"llm_completion": "ai_responses",
|
|
34
|
+
"agent_completion": "ai_responses",
|
|
35
|
+
"agent_tool_dispatch": "ai_responses",
|
|
36
|
+
"agent_tool_result": "ai_responses",
|
|
37
|
+
"agent_skill_invoke": "ai_responses",
|
|
38
|
+
"agent_session_event": "ai_responses",
|
|
39
|
+
"inline_completion_shown": "ai_responses",
|
|
40
|
+
"inline_completion_accepted": "ai_responses",
|
|
41
|
+
"inline_completion_rejected": "ai_responses",
|
|
42
|
+
"browser_visit": "browser",
|
|
43
|
+
"browser_search": "browser",
|
|
44
|
+
"browser_tab_switch": "browser",
|
|
45
|
+
"browser_copy": "browser",
|
|
46
|
+
"browser_ai_chat": "browser",
|
|
47
|
+
"music_playing": "music",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Maps capture categories to (event_type, field) pairs for field-level gating.
|
|
51
|
+
# When the category is disabled, these fields are stripped from emitted events.
|
|
52
|
+
# When enabled (code_capture), these fields are populated by the agent.
|
|
53
|
+
_FIELD_GATES: dict[str, list[tuple[str, str]]] = {
|
|
54
|
+
"command_output": [("terminal_cmd", "output_snippet")],
|
|
55
|
+
"code_capture": [("file_edit", "diff"), ("git_commit", "diff")],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def init(session_id: str, live: bool = False) -> None:
|
|
60
|
+
global _session_id, _initialized, _e2e_key, _capture, _live_mode, _prev_hash
|
|
61
|
+
_session_id = session_id
|
|
62
|
+
_initialized = True
|
|
63
|
+
_live_mode = live
|
|
64
|
+
_prev_hash = "genesis"
|
|
65
|
+
from methodproof import config
|
|
66
|
+
cfg = config.load()
|
|
67
|
+
raw = cfg.get("e2e_key", "")
|
|
68
|
+
_e2e_key = bytes.fromhex(raw) if raw else None
|
|
69
|
+
_capture = cfg.get("capture", {})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def log(level: str, event: str, **kw: object) -> None:
|
|
73
|
+
entry = {"ts": time.time(), "level": level, "event": event, "sid": _session_id, **kw}
|
|
74
|
+
sys.stderr.write(json.dumps(entry, default=str) + "\n")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def emit(event_type: str, metadata: dict[str, Any]) -> None:
|
|
78
|
+
if not _initialized:
|
|
79
|
+
log("warning", "emit.before_init", type=event_type)
|
|
80
|
+
return
|
|
81
|
+
# Event-level consent gate
|
|
82
|
+
gate = _EVENT_GATES.get(event_type)
|
|
83
|
+
if gate and not _capture.get(gate, True):
|
|
84
|
+
return
|
|
85
|
+
# Field-level consent gate — strip opted-out fields
|
|
86
|
+
for category, pairs in _FIELD_GATES.items():
|
|
87
|
+
for etype, field in pairs:
|
|
88
|
+
if event_type == etype and not _capture.get(category, True):
|
|
89
|
+
metadata.pop(field, None)
|
|
90
|
+
|
|
91
|
+
entry = {
|
|
92
|
+
"id": uuid.uuid4().hex,
|
|
93
|
+
"session_id": _session_id,
|
|
94
|
+
"type": event_type,
|
|
95
|
+
"timestamp": time.time(),
|
|
96
|
+
"duration_ms": metadata.pop("duration_ms", 0),
|
|
97
|
+
"metadata": metadata,
|
|
98
|
+
}
|
|
99
|
+
if _e2e_key:
|
|
100
|
+
from methodproof.crypto import encrypt_metadata
|
|
101
|
+
entry["metadata"] = encrypt_metadata(dict(entry["metadata"]), _e2e_key)
|
|
102
|
+
global _prev_hash
|
|
103
|
+
from methodproof.integrity import compute_event_hash
|
|
104
|
+
entry["_chain_hash"] = compute_event_hash(entry, _prev_hash)
|
|
105
|
+
_prev_hash = entry["_chain_hash"]
|
|
106
|
+
if _live_mode:
|
|
107
|
+
from methodproof import live as live_mod
|
|
108
|
+
live_mod.send(entry)
|
|
109
|
+
with _lock:
|
|
110
|
+
_buffer.append(entry)
|
|
111
|
+
if len(_buffer) >= _FLUSH_SIZE:
|
|
112
|
+
_flush_locked()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def flush() -> None:
|
|
116
|
+
with _lock:
|
|
117
|
+
_flush_locked()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _flush_locked() -> None:
|
|
121
|
+
if not _buffer:
|
|
122
|
+
return
|
|
123
|
+
batch = list(_buffer)
|
|
124
|
+
hashes = [(e["id"], e.pop("_chain_hash")) for e in batch if "_chain_hash" in e]
|
|
125
|
+
for attempt in range(_MAX_RETRIES):
|
|
126
|
+
try:
|
|
127
|
+
store.insert_events(_session_id, batch)
|
|
128
|
+
if hashes:
|
|
129
|
+
store.insert_event_hashes(hashes)
|
|
130
|
+
_buffer.clear()
|
|
131
|
+
return
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
log("warning", "flush.retry", attempt=attempt + 1, error=str(exc))
|
|
134
|
+
time.sleep(0.1 * (attempt + 1))
|
|
135
|
+
# Final attempt failed — keep events in buffer for next flush cycle
|
|
136
|
+
log("error", "flush.failed", count=len(batch), retries=_MAX_RETRIES)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""OS-level Now Playing detection — polls Spotify / Music.app / MPRIS."""
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
import subprocess
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from methodproof.agents import base
|
|
9
|
+
|
|
10
|
+
_POLL_INTERVAL = 10
|
|
11
|
+
_HEARTBEAT_INTERVAL = 60
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_now_playing_macos() -> dict[str, str] | None:
|
|
15
|
+
script = (
|
|
16
|
+
'set output to ""\n'
|
|
17
|
+
'if application "Spotify" is running then\n'
|
|
18
|
+
' tell application "Spotify"\n'
|
|
19
|
+
' if player state is playing then\n'
|
|
20
|
+
' set output to name of current track & "|||" & artist of current track & "|||spotify_desktop"\n'
|
|
21
|
+
' end if\n'
|
|
22
|
+
' end tell\n'
|
|
23
|
+
'end if\n'
|
|
24
|
+
'if output is "" and application "Music" is running then\n'
|
|
25
|
+
' tell application "Music"\n'
|
|
26
|
+
' if player state is playing then\n'
|
|
27
|
+
' set output to name of current track & "|||" & artist of current track & "|||apple_music_desktop"\n'
|
|
28
|
+
' end if\n'
|
|
29
|
+
' end tell\n'
|
|
30
|
+
'end if\n'
|
|
31
|
+
'return output'
|
|
32
|
+
)
|
|
33
|
+
try:
|
|
34
|
+
result = subprocess.run(
|
|
35
|
+
["osascript", "-e", script],
|
|
36
|
+
capture_output=True, text=True, timeout=5,
|
|
37
|
+
)
|
|
38
|
+
parts = result.stdout.strip().split("|||")
|
|
39
|
+
if len(parts) != 3 or not parts[0]:
|
|
40
|
+
return None
|
|
41
|
+
return {"track": parts[0], "artist": parts[1], "player": parts[2]}
|
|
42
|
+
except Exception:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_now_playing_linux() -> dict[str, str] | None:
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
["playerctl", "metadata", "--format", "{{artist}}|||{{title}}|||{{playerName}}"],
|
|
50
|
+
capture_output=True, text=True, timeout=5,
|
|
51
|
+
)
|
|
52
|
+
if result.returncode != 0:
|
|
53
|
+
return None
|
|
54
|
+
parts = result.stdout.strip().split("|||")
|
|
55
|
+
if len(parts) != 3 or not parts[1]:
|
|
56
|
+
return None
|
|
57
|
+
player_map = {"spotify": "spotify_desktop", "chromium": "unknown", "firefox": "unknown"}
|
|
58
|
+
return {"track": parts[1], "artist": parts[0], "player": player_map.get(parts[2].lower(), "unknown")}
|
|
59
|
+
except FileNotFoundError:
|
|
60
|
+
return None
|
|
61
|
+
except Exception:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def start(stop: threading.Event) -> None:
|
|
66
|
+
getter = (
|
|
67
|
+
_get_now_playing_macos if platform.system() == "Darwin"
|
|
68
|
+
else _get_now_playing_linux if platform.system() == "Linux"
|
|
69
|
+
else None
|
|
70
|
+
)
|
|
71
|
+
if getter is None:
|
|
72
|
+
base.log("info", "music.unsupported_platform", platform=platform.system())
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
base.log("info", "music.started")
|
|
76
|
+
last_track_key = ""
|
|
77
|
+
last_emit_time = 0.0
|
|
78
|
+
|
|
79
|
+
while not stop.is_set():
|
|
80
|
+
info = getter()
|
|
81
|
+
now = time.time()
|
|
82
|
+
|
|
83
|
+
if info:
|
|
84
|
+
track_key = f"{info['artist']}::{info['track']}"
|
|
85
|
+
if track_key != last_track_key:
|
|
86
|
+
base.emit("music_playing", {
|
|
87
|
+
"track": info["track"], "artist": info["artist"],
|
|
88
|
+
"source": "os_media", "player": info["player"],
|
|
89
|
+
"event_kind": "track_change",
|
|
90
|
+
})
|
|
91
|
+
last_track_key = track_key
|
|
92
|
+
last_emit_time = now
|
|
93
|
+
elif now - last_emit_time >= _HEARTBEAT_INTERVAL:
|
|
94
|
+
base.emit("music_playing", {
|
|
95
|
+
"track": info["track"], "artist": info["artist"],
|
|
96
|
+
"source": "os_media", "player": info["player"],
|
|
97
|
+
"event_kind": "heartbeat",
|
|
98
|
+
})
|
|
99
|
+
last_emit_time = now
|
|
100
|
+
elif last_track_key:
|
|
101
|
+
last_track_key = ""
|
|
102
|
+
|
|
103
|
+
stop.wait(_POLL_INTERVAL)
|
|
104
|
+
|
|
105
|
+
base.log("info", "music.stopped")
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Terminal monitor — captures commands from shell hook JSONL log."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
from methodproof.agents import base
|
|
8
|
+
from methodproof.config import CMD_LOG
|
|
9
|
+
|
|
10
|
+
SENSITIVE = re.compile(
|
|
11
|
+
r"(^export\s|^set\s|password|token|secret|api[_-]?key|access[_-]?key"
|
|
12
|
+
r"|credential|private[_-]?key|Authorization:\s*Bearer|ssh\s+-i\s"
|
|
13
|
+
r"|postgres://\S+:\S+@|mysql://\S+:\S+@|--password|--secret|--token"
|
|
14
|
+
r"|AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{36})",
|
|
15
|
+
re.IGNORECASE,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
TEST_FRAMEWORKS = {
|
|
19
|
+
"pytest": re.compile(r"\bpytest\b"),
|
|
20
|
+
"jest": re.compile(r"\bjest\b|npx jest|npm test"),
|
|
21
|
+
"go_test": re.compile(r"\bgo test\b"),
|
|
22
|
+
"cargo_test": re.compile(r"\bcargo test\b"),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_PYTEST_RE = re.compile(r"(\d+) passed(?:.*?(\d+) failed)?")
|
|
26
|
+
_JEST_RE = re.compile(r"Tests:\s+(?:(\d+) failed,\s+)?(\d+) passed")
|
|
27
|
+
_CARGO_RE = re.compile(r"(\d+) passed.*?(\d+) failed")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _detect_test(command: str) -> str | None:
|
|
31
|
+
for name, pattern in TEST_FRAMEWORKS.items():
|
|
32
|
+
if pattern.search(command):
|
|
33
|
+
return name
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse_test_results(output: str, framework: str, exit_code: int) -> tuple[int, int]:
|
|
38
|
+
"""Extract pass/fail counts from test output. Falls back to exit code heuristic."""
|
|
39
|
+
if framework == "pytest":
|
|
40
|
+
m = _PYTEST_RE.search(output)
|
|
41
|
+
if m:
|
|
42
|
+
return int(m.group(1)), int(m.group(2) or 0)
|
|
43
|
+
elif framework == "jest":
|
|
44
|
+
m = _JEST_RE.search(output)
|
|
45
|
+
if m:
|
|
46
|
+
return int(m.group(2) or 0), int(m.group(1) or 0)
|
|
47
|
+
elif framework == "go_test":
|
|
48
|
+
return output.count("--- PASS"), output.count("--- FAIL")
|
|
49
|
+
elif framework == "cargo_test":
|
|
50
|
+
m = _CARGO_RE.search(output)
|
|
51
|
+
if m:
|
|
52
|
+
return int(m.group(1)), int(m.group(2))
|
|
53
|
+
# Fallback: exit code 0 = all passed, non-zero = at least 1 failure
|
|
54
|
+
if exit_code == 0:
|
|
55
|
+
return 1, 0
|
|
56
|
+
return 0, 1
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def start(stop: threading.Event) -> None:
|
|
60
|
+
"""Tail the command log and emit events until stop is set."""
|
|
61
|
+
base.log("info", "terminal.started", log=str(CMD_LOG))
|
|
62
|
+
pos = 0
|
|
63
|
+
if CMD_LOG.exists():
|
|
64
|
+
pos = CMD_LOG.stat().st_size
|
|
65
|
+
|
|
66
|
+
while not stop.is_set():
|
|
67
|
+
if not CMD_LOG.exists():
|
|
68
|
+
stop.wait(1)
|
|
69
|
+
continue
|
|
70
|
+
size = CMD_LOG.stat().st_size
|
|
71
|
+
if size <= pos:
|
|
72
|
+
stop.wait(0.5)
|
|
73
|
+
continue
|
|
74
|
+
with open(CMD_LOG) as f:
|
|
75
|
+
f.seek(pos)
|
|
76
|
+
for line in f:
|
|
77
|
+
_process(line.strip())
|
|
78
|
+
pos = f.tell()
|
|
79
|
+
|
|
80
|
+
base.log("info", "terminal.stopped")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _process(line: str) -> None:
|
|
84
|
+
if not line:
|
|
85
|
+
return
|
|
86
|
+
try:
|
|
87
|
+
entry = json.loads(line)
|
|
88
|
+
except json.JSONDecodeError:
|
|
89
|
+
return
|
|
90
|
+
command = entry.get("command", "")
|
|
91
|
+
if not command or SENSITIVE.search(command):
|
|
92
|
+
return
|
|
93
|
+
exit_code = entry.get("exit_code", 0)
|
|
94
|
+
duration = entry.get("duration_ms", 0)
|
|
95
|
+
output = entry.get("output", "")[:500]
|
|
96
|
+
# Redact output if it contains secrets
|
|
97
|
+
if SENSITIVE.search(output):
|
|
98
|
+
output = "[redacted — contains sensitive content]"
|
|
99
|
+
|
|
100
|
+
base.emit("terminal_cmd", {
|
|
101
|
+
"command": command, "exit_code": exit_code,
|
|
102
|
+
"output_snippet": output, "duration_ms": duration,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
framework = _detect_test(command)
|
|
106
|
+
if framework:
|
|
107
|
+
passed, failed = _parse_test_results(output, framework, exit_code)
|
|
108
|
+
base.emit("test_run", {
|
|
109
|
+
"framework": framework, "passed": passed, "failed": failed,
|
|
110
|
+
"duration_ms": duration,
|
|
111
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""File watcher agent — captures file changes and git commits."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
11
|
+
from watchdog.observers import Observer
|
|
12
|
+
|
|
13
|
+
from methodproof.agents import base
|
|
14
|
+
|
|
15
|
+
IGNORE_PATTERNS = re.compile(
|
|
16
|
+
r"(__pycache__|\.pyc|\.git/|node_modules|\.DS_Store|\.swp|~$)"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_MAX_DIFF_BYTES = 50_000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _git_diff_stats(repo: str, path: str) -> tuple[int, int]:
|
|
24
|
+
"""Run git diff --stat for a file, return (lines_added, lines_removed)."""
|
|
25
|
+
try:
|
|
26
|
+
result = subprocess.run(
|
|
27
|
+
["git", "-C", repo, "diff", "--numstat", "--", path],
|
|
28
|
+
capture_output=True, text=True, timeout=5,
|
|
29
|
+
)
|
|
30
|
+
line = result.stdout.strip()
|
|
31
|
+
if not line:
|
|
32
|
+
return 0, 0
|
|
33
|
+
parts = line.split("\t")
|
|
34
|
+
added = int(parts[0]) if parts[0] != "-" else 0
|
|
35
|
+
removed = int(parts[1]) if parts[1] != "-" else 0
|
|
36
|
+
return added, removed
|
|
37
|
+
except Exception:
|
|
38
|
+
return 0, 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _git_diff_content(repo: str, path: str) -> str:
|
|
42
|
+
"""Get full diff content for a file, capped at 50KB."""
|
|
43
|
+
try:
|
|
44
|
+
result = subprocess.run(
|
|
45
|
+
["git", "-C", repo, "diff", "--", path],
|
|
46
|
+
capture_output=True, text=True, timeout=5,
|
|
47
|
+
)
|
|
48
|
+
return result.stdout[:_MAX_DIFF_BYTES]
|
|
49
|
+
except Exception:
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _git_show_diff(repo: str, sha: str) -> str:
|
|
54
|
+
"""Get full diff for a commit, capped at 50KB."""
|
|
55
|
+
try:
|
|
56
|
+
result = subprocess.run(
|
|
57
|
+
["git", "-C", repo, "show", "--format=", sha],
|
|
58
|
+
capture_output=True, text=True, timeout=10,
|
|
59
|
+
)
|
|
60
|
+
return result.stdout[:_MAX_DIFF_BYTES]
|
|
61
|
+
except Exception:
|
|
62
|
+
return ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _Handler(FileSystemEventHandler):
|
|
66
|
+
def __init__(self, watch_dir: str) -> None:
|
|
67
|
+
self._root = watch_dir
|
|
68
|
+
self._hashes: dict[str, str] = {}
|
|
69
|
+
|
|
70
|
+
def _relpath(self, path: str) -> str:
|
|
71
|
+
return os.path.relpath(path, self._root)
|
|
72
|
+
|
|
73
|
+
def on_created(self, event: FileSystemEvent) -> None:
|
|
74
|
+
if event.is_directory or IGNORE_PATTERNS.search(event.src_path):
|
|
75
|
+
return
|
|
76
|
+
path = self._relpath(event.src_path)
|
|
77
|
+
lang = Path(event.src_path).suffix.lstrip(".")
|
|
78
|
+
try:
|
|
79
|
+
size = os.path.getsize(event.src_path)
|
|
80
|
+
except OSError:
|
|
81
|
+
size = 0
|
|
82
|
+
base.emit("file_create", {"path": path, "size": size, "language": lang})
|
|
83
|
+
|
|
84
|
+
def on_modified(self, event: FileSystemEvent) -> None:
|
|
85
|
+
if event.is_directory or IGNORE_PATTERNS.search(event.src_path):
|
|
86
|
+
return
|
|
87
|
+
# Skip if content unchanged (watchdog fires on metadata changes)
|
|
88
|
+
try:
|
|
89
|
+
content = Path(event.src_path).read_bytes()
|
|
90
|
+
except OSError:
|
|
91
|
+
return
|
|
92
|
+
path = self._relpath(event.src_path)
|
|
93
|
+
h = hashlib.md5(content).hexdigest()
|
|
94
|
+
if self._hashes.get(path) == h:
|
|
95
|
+
return
|
|
96
|
+
self._hashes[path] = h
|
|
97
|
+
|
|
98
|
+
added, removed = _git_diff_stats(self._root, path)
|
|
99
|
+
lang = Path(event.src_path).suffix.lstrip(".")
|
|
100
|
+
meta: dict[str, object] = {
|
|
101
|
+
"path": path, "language": lang,
|
|
102
|
+
"lines_added": added, "lines_removed": removed,
|
|
103
|
+
}
|
|
104
|
+
diff = _git_diff_content(self._root, path)
|
|
105
|
+
if diff:
|
|
106
|
+
meta["diff"] = diff
|
|
107
|
+
base.emit("file_edit", meta)
|
|
108
|
+
|
|
109
|
+
def on_deleted(self, event: FileSystemEvent) -> None:
|
|
110
|
+
if event.is_directory or IGNORE_PATTERNS.search(event.src_path):
|
|
111
|
+
return
|
|
112
|
+
path = self._relpath(event.src_path)
|
|
113
|
+
self._hashes.pop(path, None)
|
|
114
|
+
base.emit("file_delete", {"path": path})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _poll_git(watch_dir: str, stop: threading.Event) -> None:
|
|
118
|
+
"""Poll .git/refs for new commits every 2 seconds."""
|
|
119
|
+
git_dir = Path(watch_dir) / ".git"
|
|
120
|
+
if not git_dir.exists():
|
|
121
|
+
return
|
|
122
|
+
seen: set[str] = set()
|
|
123
|
+
refs = git_dir / "refs" / "heads"
|
|
124
|
+
while not stop.is_set():
|
|
125
|
+
try:
|
|
126
|
+
for ref in refs.iterdir():
|
|
127
|
+
sha = ref.read_text().strip()
|
|
128
|
+
if sha not in seen:
|
|
129
|
+
seen.add(sha)
|
|
130
|
+
if len(seen) > 1:
|
|
131
|
+
_log_commit(watch_dir, sha)
|
|
132
|
+
except OSError:
|
|
133
|
+
pass
|
|
134
|
+
stop.wait(2)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _log_commit(watch_dir: str, sha: str) -> None:
|
|
138
|
+
try:
|
|
139
|
+
msg = subprocess.run(
|
|
140
|
+
["git", "-C", watch_dir, "log", "-1", "--format=%s", sha],
|
|
141
|
+
capture_output=True, text=True, timeout=5,
|
|
142
|
+
).stdout.strip()
|
|
143
|
+
files = subprocess.run(
|
|
144
|
+
["git", "-C", watch_dir, "diff-tree", "--no-commit-id", "-r", "--name-only", sha],
|
|
145
|
+
capture_output=True, text=True, timeout=5,
|
|
146
|
+
).stdout.strip().splitlines()
|
|
147
|
+
except Exception:
|
|
148
|
+
msg, files = "", []
|
|
149
|
+
meta: dict[str, object] = {"hash": sha[:7], "message": msg, "files_changed": files}
|
|
150
|
+
diff = _git_show_diff(watch_dir, sha)
|
|
151
|
+
if diff:
|
|
152
|
+
meta["diff"] = diff
|
|
153
|
+
base.emit("git_commit", meta)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def start(watch_dir: str, stop: threading.Event) -> None:
|
|
157
|
+
"""Run file watcher + git poller until stop is set."""
|
|
158
|
+
handler = _Handler(watch_dir)
|
|
159
|
+
observer = Observer()
|
|
160
|
+
observer.schedule(handler, watch_dir, recursive=True)
|
|
161
|
+
observer.start()
|
|
162
|
+
|
|
163
|
+
git_thread = threading.Thread(target=_poll_git, args=(watch_dir, stop), daemon=True)
|
|
164
|
+
git_thread.start()
|
|
165
|
+
|
|
166
|
+
base.log("info", "watcher.started", dir=watch_dir)
|
|
167
|
+
stop.wait()
|
|
168
|
+
observer.stop()
|
|
169
|
+
observer.join(timeout=3)
|
|
170
|
+
base.log("info", "watcher.stopped")
|
methodproof/bridge.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Local HTTP bridge — accepts browser extension events into SQLite."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from methodproof import store
|
|
11
|
+
from methodproof.agents import base
|
|
12
|
+
|
|
13
|
+
_session_id = ""
|
|
14
|
+
MAX_BODY = 10 * 1024 * 1024 # 10 MB
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
18
|
+
def log_message(self, fmt: str, *args: Any) -> None:
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
def do_GET(self) -> None:
|
|
22
|
+
if self.path == "/session":
|
|
23
|
+
self._json({"session_id": _session_id, "active": bool(_session_id)})
|
|
24
|
+
else:
|
|
25
|
+
self.send_error(404)
|
|
26
|
+
|
|
27
|
+
def do_POST(self) -> None:
|
|
28
|
+
if self.path == "/events":
|
|
29
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
30
|
+
if length > MAX_BODY:
|
|
31
|
+
self.send_error(413, "Request too large")
|
|
32
|
+
return
|
|
33
|
+
try:
|
|
34
|
+
body = json.loads(self.rfile.read(length)) if length else {}
|
|
35
|
+
except (json.JSONDecodeError, ValueError):
|
|
36
|
+
self.send_error(400, "Invalid JSON")
|
|
37
|
+
return
|
|
38
|
+
events = body.get("events", [])
|
|
39
|
+
for e in events:
|
|
40
|
+
e.setdefault("id", uuid.uuid4().hex)
|
|
41
|
+
e.setdefault("timestamp", time.time())
|
|
42
|
+
e.setdefault("duration_ms", 0)
|
|
43
|
+
e.setdefault("metadata", e.get("metadata", {}))
|
|
44
|
+
accepted = 0
|
|
45
|
+
if events:
|
|
46
|
+
try:
|
|
47
|
+
store.insert_events(_session_id, events)
|
|
48
|
+
accepted = len(events)
|
|
49
|
+
except Exception:
|
|
50
|
+
self.send_error(500, "Storage error")
|
|
51
|
+
return
|
|
52
|
+
self._json({"accepted": accepted})
|
|
53
|
+
else:
|
|
54
|
+
self.send_error(404)
|
|
55
|
+
|
|
56
|
+
def do_OPTIONS(self) -> None:
|
|
57
|
+
self.send_response(204)
|
|
58
|
+
self._cors()
|
|
59
|
+
self.end_headers()
|
|
60
|
+
|
|
61
|
+
def _json(self, data: Any) -> None:
|
|
62
|
+
body = json.dumps(data).encode()
|
|
63
|
+
self.send_response(200)
|
|
64
|
+
self.send_header("Content-Type", "application/json")
|
|
65
|
+
self._cors()
|
|
66
|
+
self.end_headers()
|
|
67
|
+
self.wfile.write(body)
|
|
68
|
+
|
|
69
|
+
def _cors(self) -> None:
|
|
70
|
+
origin = self.headers.get("Origin", "")
|
|
71
|
+
allowed = origin.startswith("chrome-extension://") or origin.startswith("http://localhost")
|
|
72
|
+
self.send_header("Access-Control-Allow-Origin", origin if allowed else "http://localhost:9877")
|
|
73
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
74
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def start(session_id: str, stop: threading.Event, port: int = 9877) -> None:
|
|
78
|
+
global _session_id
|
|
79
|
+
_session_id = session_id
|
|
80
|
+
server = HTTPServer(("127.0.0.1", port), _Handler)
|
|
81
|
+
server.timeout = 1
|
|
82
|
+
base.log("info", "bridge.started", port=port)
|
|
83
|
+
while not stop.is_set():
|
|
84
|
+
server.handle_request()
|
|
85
|
+
server.server_close()
|
|
86
|
+
base.log("info", "bridge.stopped")
|