claude-session-logger 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.
- claude_session_logger/__init__.py +3 -0
- claude_session_logger/_setup.py +176 -0
- claude_session_logger/classify.py +73 -0
- claude_session_logger/cli.py +336 -0
- claude_session_logger/cost.py +28 -0
- claude_session_logger/db.py +333 -0
- claude_session_logger/identity.py +23 -0
- claude_session_logger/memory.py +296 -0
- claude_session_logger/prices.json +5 -0
- claude_session_logger/resolve.py +21 -0
- claude_session_logger/transcript.py +89 -0
- claude_session_logger-0.1.0.dist-info/METADATA +458 -0
- claude_session_logger-0.1.0.dist-info/RECORD +17 -0
- claude_session_logger-0.1.0.dist-info/WHEEL +5 -0
- claude_session_logger-0.1.0.dist-info/entry_points.txt +4 -0
- claude_session_logger-0.1.0.dist-info/licenses/LICENSE +21 -0
- claude_session_logger-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Post-install setup: LaunchAgent + Claude Code hooks + token check.
|
|
2
|
+
|
|
3
|
+
Installed as the `claude-session-logger-setup` command. Idempotent: re-running
|
|
4
|
+
migrates old script-path hooks to the installed commands and never duplicates.
|
|
5
|
+
|
|
6
|
+
The flush LaunchAgent invokes `<python> -m claude_session_logger.cli flush`; it
|
|
7
|
+
does NOT embed the token — the code resolves it from ~/.config/motherduck/token
|
|
8
|
+
in-process, so no secret lands in the plist.
|
|
9
|
+
"""
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import plistlib
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
LABEL = "com.keithfajardo.claude-session-logger.flush"
|
|
20
|
+
DEFAULT_SETTINGS = Path.home() / ".claude" / "settings.json"
|
|
21
|
+
DEFAULT_PLIST = Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
|
|
22
|
+
TOKEN_FILE = Path.home() / ".config" / "motherduck" / "token"
|
|
23
|
+
|
|
24
|
+
# Substrings marking a hook command as managed by THIS tool. Used to strip stale
|
|
25
|
+
# entries (old absolute script paths included) before re-adding, so a re-run or a
|
|
26
|
+
# migration from the pre-package layout is idempotent.
|
|
27
|
+
MANAGED_MARKERS = ("claude_session_logger", "claude-session-logger",
|
|
28
|
+
"log_session.py", "memory_sync.py", LABEL)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _cli(*a):
|
|
32
|
+
return " ".join([sys.executable, "-m", "claude_session_logger.cli", *a])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _mem(*a):
|
|
36
|
+
return " ".join([sys.executable, "-m", "claude_session_logger.memory", *a])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _kick():
|
|
40
|
+
return f"launchctl kickstart gui/$(id -u)/{LABEL}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def desired_hooks():
|
|
44
|
+
"""The hook blocks this tool owns, keyed by Claude Code event."""
|
|
45
|
+
return {
|
|
46
|
+
"SessionStart": [{
|
|
47
|
+
"hooks": [
|
|
48
|
+
{"type": "command", "command": _cli("recent")},
|
|
49
|
+
{"type": "command", "command": _cli("inbox", "--count")},
|
|
50
|
+
{"type": "command", "command": _kick()},
|
|
51
|
+
{"type": "command",
|
|
52
|
+
"command": _mem("pull", "2>&1", "||", "true"),
|
|
53
|
+
"timeout": 30,
|
|
54
|
+
"statusMessage": "Pulling memory from MotherDuck"},
|
|
55
|
+
]
|
|
56
|
+
}],
|
|
57
|
+
"PostToolUse": [{
|
|
58
|
+
"matcher": "Write|Edit",
|
|
59
|
+
"hooks": [{"type": "command",
|
|
60
|
+
"command": _mem("hook", "2>/dev/null", "||", "true"),
|
|
61
|
+
"async": True}],
|
|
62
|
+
}],
|
|
63
|
+
"SessionEnd": [{
|
|
64
|
+
"hooks": [{"type": "command",
|
|
65
|
+
"command": _cli("record", "--no-sync") + "; " + _kick()}],
|
|
66
|
+
}],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_managed(cmd):
|
|
71
|
+
return any(m in cmd for m in MANAGED_MARKERS)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _strip_managed(blocks):
|
|
75
|
+
"""Drop our hook entries (and blocks left empty) from one event's block list.
|
|
76
|
+
|
|
77
|
+
Blocks without a "hooks" key are left untouched (not ours). A block whose
|
|
78
|
+
every command is managed disappears entirely, so re-running can't duplicate.
|
|
79
|
+
"""
|
|
80
|
+
out = []
|
|
81
|
+
for blk in blocks:
|
|
82
|
+
if "hooks" not in blk:
|
|
83
|
+
out.append(blk)
|
|
84
|
+
continue
|
|
85
|
+
kept = [h for h in blk["hooks"] if not _is_managed(h.get("command", ""))]
|
|
86
|
+
if kept:
|
|
87
|
+
nb = dict(blk)
|
|
88
|
+
nb["hooks"] = kept
|
|
89
|
+
out.append(nb)
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def merge_hooks(settings, desired):
|
|
94
|
+
hooks = settings.setdefault("hooks", {})
|
|
95
|
+
for event, blocks in desired.items():
|
|
96
|
+
hooks[event] = _strip_managed(hooks.get(event, [])) + blocks
|
|
97
|
+
return settings
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def setup_hooks(settings_path=DEFAULT_SETTINGS):
|
|
101
|
+
if settings_path.exists():
|
|
102
|
+
settings = json.loads(settings_path.read_text())
|
|
103
|
+
shutil.copy(settings_path, settings_path.with_suffix(".json.bak"))
|
|
104
|
+
backup = " (backup: settings.json.bak)"
|
|
105
|
+
else:
|
|
106
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
settings, backup = {}, ""
|
|
108
|
+
merge_hooks(settings, desired_hooks())
|
|
109
|
+
settings_path.write_text(json.dumps(settings, indent=2) + "\n")
|
|
110
|
+
print(f"✓ hooks merged into {settings_path}{backup}")
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def setup_launchd(plist_path=DEFAULT_PLIST, load=True):
|
|
115
|
+
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
plist = {
|
|
117
|
+
"Label": LABEL,
|
|
118
|
+
"ProgramArguments": ["/bin/sh", "-c", f"sleep 5; {_cli('flush')}"],
|
|
119
|
+
"RunAtLoad": False,
|
|
120
|
+
}
|
|
121
|
+
with open(plist_path, "wb") as f:
|
|
122
|
+
plistlib.dump(plist, f)
|
|
123
|
+
print(f"✓ LaunchAgent written to {plist_path}")
|
|
124
|
+
if not load:
|
|
125
|
+
return True
|
|
126
|
+
uid = os.getuid()
|
|
127
|
+
# bootout first (ignore "not loaded"), then bootstrap the fresh definition.
|
|
128
|
+
subprocess.run(["launchctl", "bootout", f"gui/{uid}/{LABEL}"],
|
|
129
|
+
capture_output=True)
|
|
130
|
+
r = subprocess.run(["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)],
|
|
131
|
+
capture_output=True, text=True)
|
|
132
|
+
if r.returncode == 0:
|
|
133
|
+
print("✓ LaunchAgent loaded")
|
|
134
|
+
else:
|
|
135
|
+
print(f"⚠ load it manually: launchctl bootstrap gui/{uid} {plist_path}")
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def check_token(token_file=TOKEN_FILE):
|
|
140
|
+
if token_file.exists():
|
|
141
|
+
print(f"✓ MotherDuck token present at {token_file}")
|
|
142
|
+
return True
|
|
143
|
+
print(f"⚠ no MotherDuck token at {token_file}\n"
|
|
144
|
+
" Authenticate: pip install motherduck && motherduck auth\n"
|
|
145
|
+
" (or write the token file yourself, chmod 600). Local logging works "
|
|
146
|
+
"without it; cloud sync stays off until the token exists.")
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def run_init():
|
|
151
|
+
subprocess.run([sys.executable, "-m", "claude_session_logger.cli", "init"],
|
|
152
|
+
check=False)
|
|
153
|
+
print("✓ local schema initialized")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def main(argv=None):
|
|
157
|
+
ap = argparse.ArgumentParser(prog="claude-session-logger-setup")
|
|
158
|
+
ap.add_argument("--settings", type=Path, default=DEFAULT_SETTINGS,
|
|
159
|
+
help="path to Claude Code settings.json")
|
|
160
|
+
ap.add_argument("--no-launchd", action="store_true",
|
|
161
|
+
help="skip the flush LaunchAgent (e.g. non-macOS)")
|
|
162
|
+
ap.add_argument("--no-load", action="store_true",
|
|
163
|
+
help="write the plist but don't launchctl-load it")
|
|
164
|
+
a = ap.parse_args(argv)
|
|
165
|
+
print("Setting up claude-session-logger…\n")
|
|
166
|
+
run_init()
|
|
167
|
+
setup_hooks(a.settings)
|
|
168
|
+
if not a.no_launchd:
|
|
169
|
+
setup_launchd(load=not a.no_load)
|
|
170
|
+
ok = check_token()
|
|
171
|
+
print("\nDone." if ok else "\nDone (cloud sync pending token).")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
sys.exit(main())
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Infer a session's `session_type`.
|
|
2
|
+
|
|
3
|
+
Built-in DEFAULT_RULES are the template. Users extend/override them with an optional
|
|
4
|
+
YAML file at ~/.claude/claude-session-logger/session_types.yaml — user rules are
|
|
5
|
+
checked FIRST (so they can override a template type or add new ones), then defaults,
|
|
6
|
+
then the `general` fallback. First match wins.
|
|
7
|
+
|
|
8
|
+
This runs inside the SessionEnd hook (no TTY), so loading must never raise: a missing
|
|
9
|
+
file, missing PyYAML, or malformed YAML all degrade silently to the defaults.
|
|
10
|
+
"""
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
CONFIG_PATH = (Path.home() / ".claude" / "claude-session-logger"
|
|
14
|
+
/ "session_types.yaml")
|
|
15
|
+
|
|
16
|
+
# The template. Each rule: a `type` plus case-insensitive substrings to match against
|
|
17
|
+
# the session's skills and/or its "project cwd" text. Same shape the YAML file uses.
|
|
18
|
+
DEFAULT_RULES = [
|
|
19
|
+
{"type": "dbt", "skills": ["dbt", "kimball"], "path": ["dbt"]},
|
|
20
|
+
{"type": "debugging", "skills": ["systematic-debugging"]},
|
|
21
|
+
{"type": "research", "skills": ["deep-research", "brainstorming", "research"]},
|
|
22
|
+
{"type": "planning", "skills": ["writing-plans", "executing-plans"]},
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _normalize_rules(data):
|
|
27
|
+
"""Coerce parsed YAML into clean rule dicts; drop anything malformed."""
|
|
28
|
+
out = []
|
|
29
|
+
for r in data if isinstance(data, list) else []:
|
|
30
|
+
if isinstance(r, dict) and r.get("type"):
|
|
31
|
+
out.append({
|
|
32
|
+
"type": str(r["type"]),
|
|
33
|
+
"skills": [str(s).lower() for s in (r.get("skills") or [])],
|
|
34
|
+
"path": [str(p).lower() for p in (r.get("path") or [])],
|
|
35
|
+
})
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_user_rules(path=CONFIG_PATH):
|
|
40
|
+
"""User rules from the YAML file, or [] — never raises (hook-safe)."""
|
|
41
|
+
if not path.exists():
|
|
42
|
+
return []
|
|
43
|
+
try:
|
|
44
|
+
import yaml # lazy: package imports fine even without PyYAML installed
|
|
45
|
+
except ImportError:
|
|
46
|
+
return []
|
|
47
|
+
try:
|
|
48
|
+
data = yaml.safe_load(path.read_text()) or []
|
|
49
|
+
except Exception:
|
|
50
|
+
return [] # malformed YAML → ignore, fall back to defaults
|
|
51
|
+
return _normalize_rules(data)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def effective_rules():
|
|
55
|
+
"""User rules first (override/extend), then the built-in template."""
|
|
56
|
+
return load_user_rules() + DEFAULT_RULES
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _matches(rule, skills_lc, path_text):
|
|
60
|
+
# .get/.lower so hand-written DEFAULT_RULES (may omit a key) and normalized user
|
|
61
|
+
# rules both work.
|
|
62
|
+
if any(kw.lower() in sk for kw in rule.get("skills", []) for sk in skills_lc):
|
|
63
|
+
return True
|
|
64
|
+
return any(kw.lower() in path_text for kw in rule.get("path", []))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def infer_session_type(skills_used, project, cwd, rules=None):
|
|
68
|
+
skills_lc = [str(x).lower() for x in (skills_used or [])]
|
|
69
|
+
path_text = f"{project or ''} {cwd or ''}".lower()
|
|
70
|
+
for rule in (effective_rules() if rules is None else rules):
|
|
71
|
+
if _matches(rule, skills_lc, path_text):
|
|
72
|
+
return rule["type"]
|
|
73
|
+
return "general"
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import classify
|
|
8
|
+
from . import cost as cost_mod
|
|
9
|
+
from . import db
|
|
10
|
+
from . import identity
|
|
11
|
+
from . import resolve
|
|
12
|
+
from . import transcript
|
|
13
|
+
|
|
14
|
+
SYNC_LOG = Path.home() / ".claude" / "claude-session-logger" / "sync.log"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _log_error(msg):
|
|
18
|
+
SYNC_LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
with open(SYNC_LOG, "a") as f:
|
|
20
|
+
f.write(msg + "\n")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _minutes(start, end):
|
|
24
|
+
if not start or not end:
|
|
25
|
+
return None
|
|
26
|
+
parse = lambda s: datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
27
|
+
return round((parse(end) - parse(start)).total_seconds() / 60, 2)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _norm_ts(s):
|
|
31
|
+
# DuckDB TIMESTAMP cast rejects the trailing 'Z' — strip it for storage.
|
|
32
|
+
return s.replace("Z", "") if s else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_row(data, payload, cost_usd, session_type):
|
|
36
|
+
cwd = data["cwd"] or payload.get("cwd")
|
|
37
|
+
ts = data["timestamps"]
|
|
38
|
+
started = ts[0] if ts else None
|
|
39
|
+
ended = ts[-1] if ts else None
|
|
40
|
+
return {
|
|
41
|
+
"session_id": data["session_id"] or payload.get("session_id"),
|
|
42
|
+
"project": Path(cwd).name if cwd else None,
|
|
43
|
+
"cwd": cwd,
|
|
44
|
+
"session_date": started[:10] if started else None,
|
|
45
|
+
"started_at": _norm_ts(started),
|
|
46
|
+
"ended_at": _norm_ts(ended),
|
|
47
|
+
"duration_min": _minutes(started, ended),
|
|
48
|
+
"models": ",".join(data["models"]),
|
|
49
|
+
"input_tokens": data["input_tokens"],
|
|
50
|
+
"output_tokens": data["output_tokens"],
|
|
51
|
+
"cache_write_tokens": data["cache_write_tokens"],
|
|
52
|
+
"cache_read_tokens": data["cache_read_tokens"],
|
|
53
|
+
"total_tokens": data["total_tokens"],
|
|
54
|
+
"cost_usd": cost_usd,
|
|
55
|
+
"message_count": data["message_count"],
|
|
56
|
+
"tool_call_count": data["tool_call_count"],
|
|
57
|
+
"skills_used": ",".join(data["skills_used"]),
|
|
58
|
+
"git_branch": data["git_branch"],
|
|
59
|
+
"cc_version": data["cc_version"],
|
|
60
|
+
"title": data["title"],
|
|
61
|
+
"session_type": session_type,
|
|
62
|
+
"created_by": identity.current_user(),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _try_sync(con):
|
|
67
|
+
try:
|
|
68
|
+
db.sync(con, cloud_dsn=db.CLOUD_DSN)
|
|
69
|
+
except Exception as e: # cloud unreachable / token missing — never fatal
|
|
70
|
+
_log_error(f"sync failed: {e}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_init(args, stdin):
|
|
74
|
+
con = db.connect(db.LOCAL_DB)
|
|
75
|
+
db.create_tables(con)
|
|
76
|
+
con.close()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cmd_record(args, stdin):
|
|
80
|
+
payload = json.load(stdin)
|
|
81
|
+
records = transcript.read_transcript(payload["transcript_path"])
|
|
82
|
+
data = transcript.parse_lines(records)
|
|
83
|
+
prices = cost_mod.load_prices()
|
|
84
|
+
cost_usd, unknown = cost_mod.compute_cost(data["usage_by_model"], prices)
|
|
85
|
+
if unknown:
|
|
86
|
+
_log_error(f"unknown models (cost=0 for these): {unknown}")
|
|
87
|
+
cwd = data["cwd"] or payload.get("cwd")
|
|
88
|
+
project = Path(cwd).name if cwd else None
|
|
89
|
+
session_type = classify.infer_session_type(data["skills_used"], project, cwd)
|
|
90
|
+
row = build_row(data, payload, cost_usd, session_type)
|
|
91
|
+
con = db.connect(db.LOCAL_DB)
|
|
92
|
+
db.create_tables(con)
|
|
93
|
+
db.upsert_session(con, row)
|
|
94
|
+
if not getattr(args, "no_sync", False):
|
|
95
|
+
_try_sync(con)
|
|
96
|
+
con.close()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_log(args, stdin):
|
|
100
|
+
session_id = args.session_id or resolve.resolve_session_id()
|
|
101
|
+
con = db.connect(db.LOCAL_DB)
|
|
102
|
+
db.create_tables(con)
|
|
103
|
+
entry_id = db.insert_log_entry(con, session_id, args.category, args.status,
|
|
104
|
+
args.title, args.body,
|
|
105
|
+
created_by=identity.current_user())
|
|
106
|
+
_try_sync(con)
|
|
107
|
+
con.close()
|
|
108
|
+
# Surface the id so it can be resolved/updated later without a separate list.
|
|
109
|
+
print(f"logged {args.category} ({args.status}) id={entry_id}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _resolve_entry_id(con, args):
|
|
113
|
+
"""Return the entry id to act on, from --id or a --title-match lookup.
|
|
114
|
+
|
|
115
|
+
On an unusable title match (no hit / multiple hits), writes a message to
|
|
116
|
+
stderr and returns None so the caller can exit non-zero.
|
|
117
|
+
"""
|
|
118
|
+
if args.id:
|
|
119
|
+
return args.id
|
|
120
|
+
matches = db.find_log_entries_by_title(con, args.title_match)
|
|
121
|
+
if not matches:
|
|
122
|
+
sys.stderr.write(f"no log entry with title matching {args.title_match!r}\n")
|
|
123
|
+
return None
|
|
124
|
+
if len(matches) > 1:
|
|
125
|
+
sys.stderr.write(
|
|
126
|
+
f"{len(matches)} entries match {args.title_match!r} — disambiguate by --id:\n")
|
|
127
|
+
for eid, cat, status, title in matches:
|
|
128
|
+
sys.stderr.write(f" {eid} [{cat}/{status}] {title}\n")
|
|
129
|
+
return None
|
|
130
|
+
return matches[0][0]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_resolve(args, stdin):
|
|
134
|
+
if not args.id and not args.title_match:
|
|
135
|
+
sys.stderr.write("resolve needs --id or --title-match\n")
|
|
136
|
+
raise SystemExit(2)
|
|
137
|
+
con = db.connect(db.LOCAL_DB)
|
|
138
|
+
db.create_tables(con)
|
|
139
|
+
entry_id = _resolve_entry_id(con, args)
|
|
140
|
+
if entry_id is None:
|
|
141
|
+
con.close()
|
|
142
|
+
raise SystemExit(1)
|
|
143
|
+
ok = db.update_log_status(con, entry_id, args.status, args.note,
|
|
144
|
+
updated_by=identity.current_user())
|
|
145
|
+
if not ok:
|
|
146
|
+
sys.stderr.write(f"no log entry with id {entry_id}\n")
|
|
147
|
+
con.close()
|
|
148
|
+
raise SystemExit(1)
|
|
149
|
+
_try_sync(con)
|
|
150
|
+
con.close()
|
|
151
|
+
print(f"{entry_id} -> {args.status}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_list(args, stdin):
|
|
155
|
+
con = db.connect(db.LOCAL_DB)
|
|
156
|
+
db.create_tables(con)
|
|
157
|
+
rows = db.list_log_entries(con, category=args.category, status=args.status,
|
|
158
|
+
open_only=args.open_only, limit=args.limit)
|
|
159
|
+
for r in rows:
|
|
160
|
+
print("\t".join("" if v is None else str(v) for v in r))
|
|
161
|
+
con.close()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cmd_show(args, stdin):
|
|
165
|
+
# Read-only: prints ONE entry's full body. Replaces hand-written duckdb
|
|
166
|
+
# one-liners (token-heavy, fragile). Address by id prefix or title words.
|
|
167
|
+
if not args.id and not args.title_match:
|
|
168
|
+
sys.stderr.write("show needs an id prefix or --title-match\n")
|
|
169
|
+
raise SystemExit(2)
|
|
170
|
+
con = db.connect(db.LOCAL_DB, read_only=True)
|
|
171
|
+
try:
|
|
172
|
+
rows = db.get_log_entries(con, id_prefix=args.id,
|
|
173
|
+
title_substr=args.title_match)
|
|
174
|
+
finally:
|
|
175
|
+
con.close()
|
|
176
|
+
if not rows:
|
|
177
|
+
target = args.id or args.title_match
|
|
178
|
+
sys.stderr.write(f"no log entry matching {target!r}\n")
|
|
179
|
+
raise SystemExit(1)
|
|
180
|
+
if len(rows) > 1:
|
|
181
|
+
sys.stderr.write(f"{len(rows)} entries match — narrow it down:\n")
|
|
182
|
+
for _id, cat, status, title, *_ in rows:
|
|
183
|
+
sys.stderr.write(f" {_id} [{cat}/{status}] {title}\n")
|
|
184
|
+
raise SystemExit(1)
|
|
185
|
+
_id, cat, status, title, body, logged, updated, cby, uby = rows[0]
|
|
186
|
+
print(f"id: {_id}")
|
|
187
|
+
print(f"type: {cat} / {status}")
|
|
188
|
+
print(f"title: {title}")
|
|
189
|
+
print(f"created: {logged} by {cby or '?'}")
|
|
190
|
+
if updated:
|
|
191
|
+
print(f"updated: {updated} by {uby or '?'}")
|
|
192
|
+
print("body:")
|
|
193
|
+
print(body or "(empty)")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def cmd_recent(args, stdin):
|
|
197
|
+
# Read-only path: no write lock needed, so readers coexist when no writer
|
|
198
|
+
# holds the file. Startup banner is non-essential — if the db is missing,
|
|
199
|
+
# locked past retries, or has no tables yet, skip silently (no traceback).
|
|
200
|
+
try:
|
|
201
|
+
con = db.connect(db.LOCAL_DB, read_only=True)
|
|
202
|
+
except Exception as e:
|
|
203
|
+
_log_error(f"recent skipped (connect): {e}")
|
|
204
|
+
return
|
|
205
|
+
try:
|
|
206
|
+
rows = db.recent_sessions(con, limit=args.limit)
|
|
207
|
+
except Exception as e:
|
|
208
|
+
_log_error(f"recent skipped (query): {e}")
|
|
209
|
+
return
|
|
210
|
+
finally:
|
|
211
|
+
con.close()
|
|
212
|
+
if not rows:
|
|
213
|
+
return
|
|
214
|
+
print(f"Recent sessions (last {len(rows)}):")
|
|
215
|
+
for session_id, session_date, project, title, _ended in rows:
|
|
216
|
+
print(f" • {session_date} [{project or '?'}] {title or '(untitled)'}")
|
|
217
|
+
print(f" resume: claude --resume {session_id}")
|
|
218
|
+
print("Continue most recent in this dir: claude --continue")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
INBOX_CMD = "claude-session-logger inbox"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cmd_inbox(args, stdin):
|
|
225
|
+
me = identity.current_user()
|
|
226
|
+
if getattr(args, "count", False):
|
|
227
|
+
# Badge path: read-only so it coexists with the flush writer and never
|
|
228
|
+
# advances the watermark (only the full view marks changes as seen).
|
|
229
|
+
try:
|
|
230
|
+
con = db.connect(db.LOCAL_DB, read_only=True)
|
|
231
|
+
except Exception as e:
|
|
232
|
+
_log_error(f"inbox --count skipped (connect): {e}")
|
|
233
|
+
return
|
|
234
|
+
try:
|
|
235
|
+
rows = db.inbox_changes(con, me, db.get_meta(con, "inbox_watermark"))
|
|
236
|
+
except Exception as e:
|
|
237
|
+
_log_error(f"inbox --count skipped (query): {e}")
|
|
238
|
+
return
|
|
239
|
+
finally:
|
|
240
|
+
con.close()
|
|
241
|
+
if rows:
|
|
242
|
+
print(f"📬 {len(rows)} shared issue update(s) — run: {INBOX_CMD}")
|
|
243
|
+
return
|
|
244
|
+
# Full view: list changes by others, then advance the watermark to now.
|
|
245
|
+
con = db.connect(db.LOCAL_DB)
|
|
246
|
+
db.create_tables(con)
|
|
247
|
+
rows = db.inbox_changes(con, me, db.get_meta(con, "inbox_watermark"))
|
|
248
|
+
if not rows:
|
|
249
|
+
print("Inbox empty — no shared issue changes by others.")
|
|
250
|
+
else:
|
|
251
|
+
print(f"Inbox — {len(rows)} change(s) by others since last check:")
|
|
252
|
+
for _id, category, status, title, actor, ts in rows:
|
|
253
|
+
print(f" • [{category}/{status}] {title}")
|
|
254
|
+
print(f" by {actor} at {ts} id={_id}")
|
|
255
|
+
now = con.execute("SELECT timezone('UTC', now())").fetchone()[0]
|
|
256
|
+
db.set_meta(con, "inbox_watermark", now)
|
|
257
|
+
con.close()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cmd_flush(args, stdin):
|
|
261
|
+
# User-initiated (e.g. /sync-issue) and may race the SessionStart LaunchAgent
|
|
262
|
+
# flush, which holds the write lock for a whole cloud round-trip. Wait it out
|
|
263
|
+
# (~36s) instead of failing fast like the hook paths.
|
|
264
|
+
try:
|
|
265
|
+
con = db.connect(db.LOCAL_DB, retries=20, base_delay=0.3)
|
|
266
|
+
except Exception as e:
|
|
267
|
+
# Clean message instead of a traceback if the lock never frees.
|
|
268
|
+
print(f"sync skipped — db busy, another sync still running ({e})")
|
|
269
|
+
return
|
|
270
|
+
db.create_tables(con)
|
|
271
|
+
pending = con.execute(
|
|
272
|
+
"SELECT count(*) FROM log_entries WHERE synced = FALSE").fetchone()[0]
|
|
273
|
+
_try_sync(con)
|
|
274
|
+
remaining = con.execute(
|
|
275
|
+
"SELECT count(*) FROM log_entries WHERE synced = FALSE").fetchone()[0]
|
|
276
|
+
total, open_ct = con.execute(
|
|
277
|
+
"SELECT count(*), count(*) FILTER (WHERE status <> 'resolved') "
|
|
278
|
+
"FROM log_entries WHERE category = 'issue'").fetchone()
|
|
279
|
+
unseen = len(db.inbox_changes(con, identity.current_user(),
|
|
280
|
+
db.get_meta(con, "inbox_watermark")))
|
|
281
|
+
con.close()
|
|
282
|
+
if remaining:
|
|
283
|
+
# _try_sync swallows cloud errors; unsynced rows still queued means it failed.
|
|
284
|
+
print(f"sync incomplete — {remaining} change(s) still queued "
|
|
285
|
+
f"(cloud unreachable? see sync.log)")
|
|
286
|
+
else:
|
|
287
|
+
print(f"synced {pending} change(s) · {total} issue(s) "
|
|
288
|
+
f"({open_ct} open) · inbox {unseen} unseen")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def main(argv=None, stdin=None):
|
|
292
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
293
|
+
stdin = stdin if stdin is not None else sys.stdin
|
|
294
|
+
p = argparse.ArgumentParser()
|
|
295
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
296
|
+
sub.add_parser("init")
|
|
297
|
+
rp_rec = sub.add_parser("record")
|
|
298
|
+
rp_rec.add_argument("--no-sync", dest="no_sync", action="store_true")
|
|
299
|
+
sub.add_parser("flush")
|
|
300
|
+
lp = sub.add_parser("log")
|
|
301
|
+
lp.add_argument("--category", required=True)
|
|
302
|
+
lp.add_argument("--status", required=True)
|
|
303
|
+
lp.add_argument("--title", required=True)
|
|
304
|
+
lp.add_argument("--body", default="")
|
|
305
|
+
lp.add_argument("--session-id", dest="session_id", default=None)
|
|
306
|
+
rp = sub.add_parser("resolve")
|
|
307
|
+
rp.add_argument("--id", default=None)
|
|
308
|
+
rp.add_argument("--title-match", dest="title_match", default=None,
|
|
309
|
+
help="resolve by case-insensitive title substring (must match exactly one)")
|
|
310
|
+
rp.add_argument("--status", required=True)
|
|
311
|
+
rp.add_argument("--note", default=None)
|
|
312
|
+
lp2 = sub.add_parser("list")
|
|
313
|
+
lp2.add_argument("--category", default=None)
|
|
314
|
+
lp2.add_argument("--status", default=None)
|
|
315
|
+
lp2.add_argument("--open", dest="open_only", action="store_true")
|
|
316
|
+
lp2.add_argument("--limit", type=int, default=None)
|
|
317
|
+
sp = sub.add_parser("show")
|
|
318
|
+
sp.add_argument("id", nargs="?", default=None,
|
|
319
|
+
help="entry id or id prefix (e.g. b045dd70)")
|
|
320
|
+
sp.add_argument("--title-match", dest="title_match", default=None,
|
|
321
|
+
help="show by case-insensitive title substring (must match one)")
|
|
322
|
+
rp_recent = sub.add_parser("recent")
|
|
323
|
+
rp_recent.add_argument("--limit", type=int, default=3)
|
|
324
|
+
rp_inbox = sub.add_parser("inbox")
|
|
325
|
+
rp_inbox.add_argument("--count", action="store_true",
|
|
326
|
+
help="print only the unseen-count badge (read-only); "
|
|
327
|
+
"without it, show the inbox and mark changes seen")
|
|
328
|
+
args = p.parse_args(argv)
|
|
329
|
+
{"init": cmd_init, "record": cmd_record,
|
|
330
|
+
"log": cmd_log, "flush": cmd_flush,
|
|
331
|
+
"resolve": cmd_resolve, "list": cmd_list, "show": cmd_show,
|
|
332
|
+
"recent": cmd_recent, "inbox": cmd_inbox}[args.cmd](args, stdin)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
if __name__ == "__main__":
|
|
336
|
+
main()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
PRICES_PATH = Path(__file__).with_name("prices.json")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_prices(path=PRICES_PATH):
|
|
8
|
+
with open(path) as f:
|
|
9
|
+
return json.load(f)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def compute_cost(usage_by_model, prices):
|
|
13
|
+
"""usage_by_model: {model: {"input","output","cache_write","cache_read": int}}.
|
|
14
|
+
prices: same shape, per 1M tokens. Returns (cost_usd, unknown_models)."""
|
|
15
|
+
total = 0.0
|
|
16
|
+
unknown = []
|
|
17
|
+
for model, u in usage_by_model.items():
|
|
18
|
+
p = prices.get(model)
|
|
19
|
+
if p is None:
|
|
20
|
+
unknown.append(model)
|
|
21
|
+
continue
|
|
22
|
+
total += (
|
|
23
|
+
u.get("input", 0) * p["input"]
|
|
24
|
+
+ u.get("output", 0) * p["output"]
|
|
25
|
+
+ u.get("cache_write", 0) * p["cache_write"]
|
|
26
|
+
+ u.get("cache_read", 0) * p["cache_read"]
|
|
27
|
+
)
|
|
28
|
+
return total / 1_000_000, unknown
|