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.
@@ -0,0 +1,3 @@
1
+ """Claude Code session logging + shared issue tracking via DuckDB + MotherDuck."""
2
+
3
+ __version__ = "0.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