mcgram 0.2.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.
- mcgram/__init__.py +3 -0
- mcgram/ask_registry.py +140 -0
- mcgram/audit.py +144 -0
- mcgram/cli.py +72 -0
- mcgram/cli_audit.py +191 -0
- mcgram/cli_channel.py +176 -0
- mcgram/cli_doctor.py +180 -0
- mcgram/cli_init.py +216 -0
- mcgram/config.py +275 -0
- mcgram/data/__init__.py +1 -0
- mcgram/data/config.example.yaml +59 -0
- mcgram/data/skill/SKILL.md +131 -0
- mcgram/dispatch.py +101 -0
- mcgram/errors.py +58 -0
- mcgram/lock.py +116 -0
- mcgram/ntfy_client.py +173 -0
- mcgram/polling.py +82 -0
- mcgram/rate_limiter.py +45 -0
- mcgram/reminders.py +136 -0
- mcgram/runtime.py +37 -0
- mcgram/server.py +249 -0
- mcgram/skill_installer.py +37 -0
- mcgram/tg_client.py +166 -0
- mcgram/tools/__init__.py +1 -0
- mcgram/tools/ask.py +127 -0
- mcgram/tools/cancel_reminder.py +33 -0
- mcgram/tools/list_reminders.py +24 -0
- mcgram/tools/send_file.py +123 -0
- mcgram/tools/send_message.py +94 -0
- mcgram/tools/send_video.py +115 -0
- mcgram/tools/set_reminder.py +57 -0
- mcgram/update_dispatcher.py +45 -0
- mcgram-0.2.0.dist-info/METADATA +311 -0
- mcgram-0.2.0.dist-info/RECORD +37 -0
- mcgram-0.2.0.dist-info/WHEEL +4 -0
- mcgram-0.2.0.dist-info/entry_points.txt +2 -0
- mcgram-0.2.0.dist-info/licenses/LICENSE +21 -0
mcgram/__init__.py
ADDED
mcgram/ask_registry.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Ask registry: open a question, await operator reply (button or freetext), or time out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import secrets
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .tg_client import TelegramClient
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("mcgram.ask")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class PendingAsk:
|
|
20
|
+
question_id: str
|
|
21
|
+
future: asyncio.Future
|
|
22
|
+
message_id: int
|
|
23
|
+
chat_id: int
|
|
24
|
+
options: list[str] | None
|
|
25
|
+
posted_at: float # epoch seconds; only replies posted AFTER this resolve the ask
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _build_keyboard(question_id: str, options: list[str]) -> dict[str, Any]:
|
|
29
|
+
"""One button per row. callback_data encodes question_id + option index."""
|
|
30
|
+
rows = [
|
|
31
|
+
[{"text": opt, "callback_data": f"{question_id}:{idx}"}]
|
|
32
|
+
for idx, opt in enumerate(options)
|
|
33
|
+
]
|
|
34
|
+
return {"inline_keyboard": rows}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AskRegistry:
|
|
38
|
+
"""In-process map of open questions; handler resolves each on operator reply or timeout."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self._pending: dict[str, PendingAsk] = {}
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def open_count(self) -> int:
|
|
45
|
+
return len(self._pending)
|
|
46
|
+
|
|
47
|
+
async def open(
|
|
48
|
+
self,
|
|
49
|
+
client: TelegramClient,
|
|
50
|
+
*,
|
|
51
|
+
chat_id: int,
|
|
52
|
+
question: str,
|
|
53
|
+
options: list[str] | None,
|
|
54
|
+
timeout_s: int,
|
|
55
|
+
) -> dict[str, Any]:
|
|
56
|
+
q_id = "q_" + secrets.token_hex(4)
|
|
57
|
+
reply_markup = _build_keyboard(q_id, options) if options else None
|
|
58
|
+
msg = await client.send_message(chat_id, question, reply_markup=reply_markup)
|
|
59
|
+
loop = asyncio.get_running_loop()
|
|
60
|
+
fut: asyncio.Future = loop.create_future()
|
|
61
|
+
self._pending[q_id] = PendingAsk(
|
|
62
|
+
question_id=q_id,
|
|
63
|
+
future=fut,
|
|
64
|
+
message_id=msg["message_id"],
|
|
65
|
+
chat_id=chat_id,
|
|
66
|
+
options=options,
|
|
67
|
+
posted_at=time.time(),
|
|
68
|
+
)
|
|
69
|
+
try:
|
|
70
|
+
value, source = await asyncio.wait_for(fut, timeout=timeout_s)
|
|
71
|
+
await _strip_keyboard(client, chat_id, msg["message_id"])
|
|
72
|
+
return {"value": value, "source": source, "question_id": q_id}
|
|
73
|
+
except TimeoutError:
|
|
74
|
+
await _mark_timed_out(client, chat_id, msg["message_id"], question)
|
|
75
|
+
return {"value": "", "source": "timeout", "question_id": q_id}
|
|
76
|
+
finally:
|
|
77
|
+
self._pending.pop(q_id, None)
|
|
78
|
+
|
|
79
|
+
async def handle_update(self, update: dict[str, Any]) -> None:
|
|
80
|
+
if cq := update.get("callback_query"):
|
|
81
|
+
await self._handle_callback(cq)
|
|
82
|
+
return
|
|
83
|
+
if msg := update.get("message"):
|
|
84
|
+
self._handle_message(msg)
|
|
85
|
+
|
|
86
|
+
async def _handle_callback(self, cq: dict[str, Any]) -> None:
|
|
87
|
+
data = cq.get("data", "")
|
|
88
|
+
q_id, _, idx_str = data.partition(":")
|
|
89
|
+
pending = self._pending.get(q_id)
|
|
90
|
+
if pending and pending.options and idx_str.isdigit():
|
|
91
|
+
idx = int(idx_str)
|
|
92
|
+
if 0 <= idx < len(pending.options) and not pending.future.done():
|
|
93
|
+
pending.future.set_result((pending.options[idx], "button"))
|
|
94
|
+
# Always answer the callback so Telegram stops showing a spinner.
|
|
95
|
+
# The client reference travels via the registry's known client (passed at open()).
|
|
96
|
+
# Since handle_update lives on AppState, we look up the client via cq's bot wiring:
|
|
97
|
+
# in mcgram, the dispatcher passes the raw update; we don't have client here.
|
|
98
|
+
# The runtime injects an `_answer` hook below.
|
|
99
|
+
if self._answer_hook is not None:
|
|
100
|
+
try:
|
|
101
|
+
await self._answer_hook(cq["id"])
|
|
102
|
+
except Exception:
|
|
103
|
+
log.exception("answer_callback_query failed")
|
|
104
|
+
|
|
105
|
+
def _handle_message(self, msg: dict[str, Any]) -> None:
|
|
106
|
+
chat_id = msg.get("chat", {}).get("id")
|
|
107
|
+
text = msg.get("text") or ""
|
|
108
|
+
msg_ts = float(msg.get("date") or 0)
|
|
109
|
+
# Resolve the oldest pending ask in the same chat posted before this message.
|
|
110
|
+
candidates = sorted(
|
|
111
|
+
(p for p in self._pending.values() if p.chat_id == chat_id and msg_ts > p.posted_at),
|
|
112
|
+
key=lambda p: p.posted_at,
|
|
113
|
+
)
|
|
114
|
+
for p in candidates:
|
|
115
|
+
if not p.future.done():
|
|
116
|
+
p.future.set_result((text, "freetext"))
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
# Hook injected by server bootstrap so we can call answer_callback_query
|
|
120
|
+
# without making the registry import the client (avoids cycle in runtime).
|
|
121
|
+
_answer_hook: Any = None
|
|
122
|
+
|
|
123
|
+
def set_answer_hook(self, hook: Any) -> None:
|
|
124
|
+
self._answer_hook = hook
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def _strip_keyboard(client: TelegramClient, chat_id: int, message_id: int) -> None:
|
|
128
|
+
try:
|
|
129
|
+
await client.edit_message_reply_markup(chat_id, message_id, reply_markup=None)
|
|
130
|
+
except Exception:
|
|
131
|
+
log.debug("edit_message_reply_markup failed (non-fatal)", exc_info=True)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def _mark_timed_out(
|
|
135
|
+
client: TelegramClient, chat_id: int, message_id: int, question: str
|
|
136
|
+
) -> None:
|
|
137
|
+
try:
|
|
138
|
+
await client.edit_message_text(chat_id, message_id, f"{question}\n\n(timed out)")
|
|
139
|
+
except Exception:
|
|
140
|
+
log.debug("edit_message_text on timeout failed (non-fatal)", exc_info=True)
|
mcgram/audit.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""JSONL audit logger with fsync, rotation, retention, and optional text redaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from datetime import UTC, datetime, timedelta, tzinfo
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("mcgram.audit")
|
|
17
|
+
|
|
18
|
+
_PRUNE_INTERVAL_S = 3600.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _resolve_tz(name: str) -> tzinfo:
|
|
22
|
+
try:
|
|
23
|
+
return ZoneInfo(name)
|
|
24
|
+
except ZoneInfoNotFoundError:
|
|
25
|
+
log.warning("unknown timezone %r; falling back to UTC", name)
|
|
26
|
+
try:
|
|
27
|
+
return ZoneInfo("UTC")
|
|
28
|
+
except ZoneInfoNotFoundError:
|
|
29
|
+
return UTC
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuditLog:
|
|
33
|
+
"""Thread-safe append-only JSONL writer.
|
|
34
|
+
|
|
35
|
+
Each record is one JSON line with `ts`, `tool`, `status` keys at minimum.
|
|
36
|
+
`fsync` is called per-write so a `kill -9` still preserves the audit trail.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
path: str | Path,
|
|
42
|
+
rotate_mb: int = 25,
|
|
43
|
+
timezone: str = "UTC",
|
|
44
|
+
redact_text: bool = False,
|
|
45
|
+
retention_days: int | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self.path = str(Path(path).expanduser())
|
|
48
|
+
self.rotate_bytes = max(1, rotate_mb) * 1024 * 1024
|
|
49
|
+
self.redact_text = redact_text
|
|
50
|
+
self.retention_days = retention_days
|
|
51
|
+
self._tz = _resolve_tz(timezone)
|
|
52
|
+
self._lock = threading.Lock()
|
|
53
|
+
self._last_prune_ts = 0.0
|
|
54
|
+
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
if retention_days:
|
|
56
|
+
with self._lock:
|
|
57
|
+
self._prune_old_entries()
|
|
58
|
+
|
|
59
|
+
def write(self, record: dict[str, Any]) -> None:
|
|
60
|
+
"""Append a record. `ts` is set automatically if missing."""
|
|
61
|
+
rec = dict(record)
|
|
62
|
+
rec.setdefault("ts", datetime.now(self._tz).isoformat(timespec="seconds"))
|
|
63
|
+
if self.redact_text and "text" in rec:
|
|
64
|
+
text = rec["text"]
|
|
65
|
+
if isinstance(text, str):
|
|
66
|
+
rec["text_len"] = len(text)
|
|
67
|
+
rec["text"] = "<redacted>"
|
|
68
|
+
line = json.dumps(rec, ensure_ascii=False, default=str) + "\n"
|
|
69
|
+
with self._lock:
|
|
70
|
+
self._maybe_rotate()
|
|
71
|
+
self._maybe_prune()
|
|
72
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
73
|
+
f.write(line)
|
|
74
|
+
f.flush()
|
|
75
|
+
os.fsync(f.fileno())
|
|
76
|
+
|
|
77
|
+
def _maybe_rotate(self) -> None:
|
|
78
|
+
try:
|
|
79
|
+
size = os.path.getsize(self.path)
|
|
80
|
+
except FileNotFoundError:
|
|
81
|
+
return
|
|
82
|
+
if size < self.rotate_bytes:
|
|
83
|
+
return
|
|
84
|
+
tail = f"{self.path}.3"
|
|
85
|
+
if os.path.exists(tail):
|
|
86
|
+
try:
|
|
87
|
+
os.remove(tail)
|
|
88
|
+
except OSError as e:
|
|
89
|
+
log.warning("rotate: failed to remove %s: %s", tail, e)
|
|
90
|
+
return
|
|
91
|
+
for i in (3, 2, 1):
|
|
92
|
+
src = self.path if i == 1 else f"{self.path}.{i - 1}"
|
|
93
|
+
dst = f"{self.path}.{i}"
|
|
94
|
+
if os.path.exists(src):
|
|
95
|
+
try:
|
|
96
|
+
os.replace(src, dst)
|
|
97
|
+
except OSError as e:
|
|
98
|
+
log.warning("rotate: %s -> %s failed: %s", src, dst, e)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
def _maybe_prune(self) -> None:
|
|
102
|
+
if not self.retention_days:
|
|
103
|
+
return
|
|
104
|
+
now = time.monotonic()
|
|
105
|
+
if now - self._last_prune_ts < _PRUNE_INTERVAL_S:
|
|
106
|
+
return
|
|
107
|
+
self._prune_old_entries()
|
|
108
|
+
|
|
109
|
+
def _prune_old_entries(self) -> None:
|
|
110
|
+
if not self.retention_days:
|
|
111
|
+
return
|
|
112
|
+
cutoff = datetime.now(self._tz) - timedelta(days=self.retention_days)
|
|
113
|
+
for path in (self.path, f"{self.path}.1", f"{self.path}.2", f"{self.path}.3"):
|
|
114
|
+
if os.path.exists(path):
|
|
115
|
+
_rewrite_newer_than(path, cutoff)
|
|
116
|
+
self._last_prune_ts = time.monotonic()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _rewrite_newer_than(path: str, cutoff: datetime) -> None:
|
|
120
|
+
tmp = f"{path}.tmp"
|
|
121
|
+
try:
|
|
122
|
+
with open(path, encoding="utf-8") as src:
|
|
123
|
+
kept = [line for line in src if _entry_newer_than(line, cutoff)]
|
|
124
|
+
with open(tmp, "w", encoding="utf-8") as dst:
|
|
125
|
+
dst.writelines(kept)
|
|
126
|
+
dst.flush()
|
|
127
|
+
os.fsync(dst.fileno())
|
|
128
|
+
os.replace(tmp, path)
|
|
129
|
+
except OSError as e:
|
|
130
|
+
log.warning("prune: rewrite %s failed: %s", path, e)
|
|
131
|
+
with contextlib.suppress(OSError):
|
|
132
|
+
os.remove(tmp)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _entry_newer_than(line: str, cutoff: datetime) -> bool:
|
|
136
|
+
"""Keep entries with `ts >= cutoff`. Malformed lines kept (fail-safe)."""
|
|
137
|
+
try:
|
|
138
|
+
rec = json.loads(line)
|
|
139
|
+
ts = datetime.fromisoformat(rec["ts"])
|
|
140
|
+
if ts.tzinfo is None:
|
|
141
|
+
ts = ts.replace(tzinfo=cutoff.tzinfo)
|
|
142
|
+
return ts >= cutoff
|
|
143
|
+
except (json.JSONDecodeError, ValueError, KeyError, TypeError):
|
|
144
|
+
return True
|
mcgram/cli.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""CLI dispatcher for mcgram. Lazy-imports heavy submodules for <500ms startup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
|
|
9
|
+
_HELP = """\
|
|
10
|
+
mcgram — notification bridge MCP for Claude Code (Telegram + ntfy.sh).
|
|
11
|
+
|
|
12
|
+
USAGE:
|
|
13
|
+
mcgram start the MCP stdio server (expects MCGRAM_CONFIG)
|
|
14
|
+
mcgram init [--force] scaffold ~/.mcgram/ + install Claude Code skill
|
|
15
|
+
mcgram doctor check config + transport connectivity (sends test pings)
|
|
16
|
+
mcgram audit [opts] analyze audit.jsonl (--since, --tool, --rejected, --tail)
|
|
17
|
+
mcgram channel list list configured channels (both transports)
|
|
18
|
+
mcgram channel add NAME CHAT_ID add a Telegram channel
|
|
19
|
+
mcgram channel add-ntfy NAME add a ntfy.sh channel (auto-generates topic)
|
|
20
|
+
mcgram channel remove NAME remove a channel
|
|
21
|
+
mcgram install-skill [--force] install / reinstall ~/.claude/skills/mcgram/SKILL.md
|
|
22
|
+
mcgram clear-lock remove stale ~/.mcgram/.lock (use if MCP fails to start)
|
|
23
|
+
mcgram --version print version
|
|
24
|
+
mcgram --help print this help
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main() -> None:
|
|
29
|
+
args = sys.argv[1:]
|
|
30
|
+
if args:
|
|
31
|
+
first = args[0]
|
|
32
|
+
if first in {"-V", "--version", "version"}:
|
|
33
|
+
print(f"mcgram {__version__}")
|
|
34
|
+
return
|
|
35
|
+
if first in {"-h", "--help", "help"}:
|
|
36
|
+
sys.stdout.write(_HELP)
|
|
37
|
+
return
|
|
38
|
+
if first == "init":
|
|
39
|
+
from .cli_init import init_config
|
|
40
|
+
force = "--force" in args[1:]
|
|
41
|
+
sys.exit(init_config(force=force))
|
|
42
|
+
if first == "doctor":
|
|
43
|
+
from .cli_doctor import doctor
|
|
44
|
+
sys.exit(doctor())
|
|
45
|
+
if first == "audit":
|
|
46
|
+
from .cli_audit import main as audit_main
|
|
47
|
+
sys.exit(audit_main(args[1:]))
|
|
48
|
+
if first == "channel":
|
|
49
|
+
from .cli_channel import main as channel_main
|
|
50
|
+
sys.exit(channel_main(args[1:]))
|
|
51
|
+
if first == "install-skill":
|
|
52
|
+
from .skill_installer import install_skill
|
|
53
|
+
force = "--force" in args[1:]
|
|
54
|
+
sys.exit(install_skill(force=force))
|
|
55
|
+
if first == "clear-lock":
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
lock = Path("~/.mcgram/.lock").expanduser()
|
|
58
|
+
if lock.exists():
|
|
59
|
+
lock.unlink()
|
|
60
|
+
print(f"removed {lock}")
|
|
61
|
+
else:
|
|
62
|
+
print(f"no lock file at {lock} (already clean)")
|
|
63
|
+
sys.exit(0)
|
|
64
|
+
print(f"unknown argument: {first}. Try `mcgram --help`.", file=sys.stderr)
|
|
65
|
+
sys.exit(2)
|
|
66
|
+
# No args → run MCP stdio server.
|
|
67
|
+
from .server import main as server_main
|
|
68
|
+
server_main()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
main()
|
mcgram/cli_audit.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""`mcgram audit` — analyze audit.jsonl (+ rotated backups). dbread-equivalent flags."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from datetime import UTC, datetime, timedelta
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
_DUR_RE = re.compile(r"^\s*(\d+)\s*([smhd])\s*$", re.IGNORECASE)
|
|
17
|
+
_UNIT_SECS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_duration(s: str) -> timedelta:
|
|
21
|
+
m = _DUR_RE.match(s)
|
|
22
|
+
if not m:
|
|
23
|
+
raise ValueError(f"bad duration {s!r}; expected e.g. 1h, 30m, 7d")
|
|
24
|
+
return timedelta(seconds=int(m.group(1)) * _UNIT_SECS[m.group(2).lower()])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _rotated_paths(base: str) -> list[str]:
|
|
28
|
+
paths = [f"{base}.3", f"{base}.2", f"{base}.1", base]
|
|
29
|
+
return [p for p in paths if os.path.exists(p)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_ts(raw: str) -> datetime | None:
|
|
33
|
+
try:
|
|
34
|
+
return datetime.fromisoformat(raw)
|
|
35
|
+
except (ValueError, TypeError):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _iter_entries(
|
|
40
|
+
base: str, *, since: timedelta | None = None, tool: str | None = None
|
|
41
|
+
) -> Iterator[dict[str, Any]]:
|
|
42
|
+
cutoff = datetime.now(UTC) - since if since else None
|
|
43
|
+
for path in _rotated_paths(base):
|
|
44
|
+
try:
|
|
45
|
+
with open(path, encoding="utf-8") as f:
|
|
46
|
+
for line in f:
|
|
47
|
+
line = line.strip()
|
|
48
|
+
if not line:
|
|
49
|
+
continue
|
|
50
|
+
try:
|
|
51
|
+
rec = json.loads(line)
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
continue
|
|
54
|
+
if tool and rec.get("tool") != tool:
|
|
55
|
+
continue
|
|
56
|
+
if cutoff is not None:
|
|
57
|
+
ts = _parse_ts(rec.get("ts", ""))
|
|
58
|
+
if ts is None:
|
|
59
|
+
continue
|
|
60
|
+
if ts.tzinfo is None:
|
|
61
|
+
ts = ts.replace(tzinfo=UTC)
|
|
62
|
+
if ts < cutoff:
|
|
63
|
+
continue
|
|
64
|
+
yield rec
|
|
65
|
+
except OSError:
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _summarize(entries: list[dict[str, Any]]) -> dict[str, Any]:
|
|
70
|
+
status_counts: dict[str, int] = {}
|
|
71
|
+
reason_counts: dict[str, int] = {}
|
|
72
|
+
tool_counts: dict[str, int] = {}
|
|
73
|
+
for rec in entries:
|
|
74
|
+
status_counts[rec.get("status", "?")] = status_counts.get(rec.get("status", "?"), 0) + 1
|
|
75
|
+
tool_counts[rec.get("tool", "?")] = tool_counts.get(rec.get("tool", "?"), 0) + 1
|
|
76
|
+
if rec.get("status") == "rejected":
|
|
77
|
+
r = rec.get("reason", "unknown")
|
|
78
|
+
reason_counts[r] = reason_counts.get(r, 0) + 1
|
|
79
|
+
return {
|
|
80
|
+
"total": len(entries),
|
|
81
|
+
"status": status_counts,
|
|
82
|
+
"tool": tool_counts,
|
|
83
|
+
"rejected_reasons": reason_counts,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _fmt_summary(s: dict[str, Any]) -> str:
|
|
88
|
+
out = [f"Total entries: {s['total']}", "", "Status:"]
|
|
89
|
+
for k, n in sorted(s["status"].items(), key=lambda x: -x[1]):
|
|
90
|
+
out.append(f" {k:<12} {n}")
|
|
91
|
+
out.append("")
|
|
92
|
+
out.append("By tool:")
|
|
93
|
+
for k, n in sorted(s["tool"].items(), key=lambda x: -x[1]):
|
|
94
|
+
out.append(f" {k:<20} {n}")
|
|
95
|
+
if s["rejected_reasons"]:
|
|
96
|
+
out.append("")
|
|
97
|
+
out.append("Rejection reasons:")
|
|
98
|
+
for r, n in sorted(s["rejected_reasons"].items(), key=lambda x: -x[1]):
|
|
99
|
+
out.append(f" {n:>5} {r}")
|
|
100
|
+
return "\n".join(out)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _fmt_rejected(entries: list[dict[str, Any]]) -> str:
|
|
104
|
+
groups: dict[str, list[dict[str, Any]]] = {}
|
|
105
|
+
for rec in entries:
|
|
106
|
+
if rec.get("status") != "rejected":
|
|
107
|
+
continue
|
|
108
|
+
groups.setdefault(rec.get("reason", "unknown"), []).append(rec)
|
|
109
|
+
if not groups:
|
|
110
|
+
return "No rejections found."
|
|
111
|
+
out: list[str] = []
|
|
112
|
+
for reason, recs in sorted(groups.items(), key=lambda x: -len(x[1])):
|
|
113
|
+
out.append(f"[{len(recs)}] {reason}")
|
|
114
|
+
for r in recs[:5]:
|
|
115
|
+
out.append(f" {r.get('ts','?')} [{r.get('tool','?')}] {r}")
|
|
116
|
+
if len(recs) > 5:
|
|
117
|
+
out.append(f" ... +{len(recs) - 5} more")
|
|
118
|
+
return "\n".join(out)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _tail(base: str, tool: str | None) -> int:
|
|
122
|
+
path = base
|
|
123
|
+
pos = os.path.getsize(path) if os.path.exists(path) else 0
|
|
124
|
+
while True:
|
|
125
|
+
try:
|
|
126
|
+
size = os.path.getsize(path)
|
|
127
|
+
except FileNotFoundError:
|
|
128
|
+
time.sleep(1)
|
|
129
|
+
continue
|
|
130
|
+
if size < pos:
|
|
131
|
+
pos = 0
|
|
132
|
+
if size > pos:
|
|
133
|
+
with open(path, encoding="utf-8") as f:
|
|
134
|
+
f.seek(pos)
|
|
135
|
+
for line in f:
|
|
136
|
+
if not line.strip():
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
rec = json.loads(line)
|
|
140
|
+
except json.JSONDecodeError:
|
|
141
|
+
continue
|
|
142
|
+
if tool and rec.get("tool") != tool:
|
|
143
|
+
continue
|
|
144
|
+
sys.stdout.write(line if line.endswith("\n") else line + "\n")
|
|
145
|
+
sys.stdout.flush()
|
|
146
|
+
pos = f.tell()
|
|
147
|
+
time.sleep(1)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _default_audit_path() -> str:
|
|
151
|
+
env = os.environ.get("MCGRAM_AUDIT_PATH")
|
|
152
|
+
if env:
|
|
153
|
+
return str(Path(env).expanduser())
|
|
154
|
+
cfg = os.environ.get("MCGRAM_CONFIG")
|
|
155
|
+
if cfg:
|
|
156
|
+
try:
|
|
157
|
+
from .config import Settings
|
|
158
|
+
return Settings.load(cfg).audit.path
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
return str(Path("~/.mcgram/audit.jsonl").expanduser())
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main(argv: list[str] | None = None) -> int:
|
|
165
|
+
p = argparse.ArgumentParser(prog="mcgram audit", description="Analyze audit.jsonl")
|
|
166
|
+
p.add_argument("--path", default=None, help="override audit.jsonl path")
|
|
167
|
+
p.add_argument("--since", help="e.g. 1h, 30m, 7d")
|
|
168
|
+
p.add_argument("--tool", help="filter by tool name")
|
|
169
|
+
p.add_argument("--rejected", action="store_true", help="show only rejections grouped")
|
|
170
|
+
p.add_argument("--tail", action="store_true", help="follow new entries")
|
|
171
|
+
args = p.parse_args(argv)
|
|
172
|
+
|
|
173
|
+
base = args.path or _default_audit_path()
|
|
174
|
+
if args.tail:
|
|
175
|
+
try:
|
|
176
|
+
return _tail(base, args.tool)
|
|
177
|
+
except KeyboardInterrupt:
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
since = _parse_duration(args.since) if args.since else None
|
|
181
|
+
entries = list(_iter_entries(base, since=since, tool=args.tool))
|
|
182
|
+
if not entries:
|
|
183
|
+
suffix = " (filtered)" if (since or args.tool) else ""
|
|
184
|
+
print(f"No audit entries found at {base}{suffix}")
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
if args.rejected:
|
|
188
|
+
print(_fmt_rejected(entries))
|
|
189
|
+
else:
|
|
190
|
+
print(_fmt_summary(_summarize(entries)))
|
|
191
|
+
return 0
|