sessionpulse 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.
- sessionpulse/__init__.py +3 -0
- sessionpulse/__main__.py +5 -0
- sessionpulse/cli.py +158 -0
- sessionpulse/config.py +90 -0
- sessionpulse/followups.py +273 -0
- sessionpulse/hook.py +39 -0
- sessionpulse/icons/sessionpulse-busy.svg +5 -0
- sessionpulse/icons/sessionpulse-idle.svg +5 -0
- sessionpulse/icons/sessionpulse-none.svg +5 -0
- sessionpulse/install.py +149 -0
- sessionpulse/learn.py +158 -0
- sessionpulse/reader.py +333 -0
- sessionpulse/server.py +122 -0
- sessionpulse/static/brand/favicon.svg +6 -0
- sessionpulse/static/brand/session-pulse-idle-animated.svg +52 -0
- sessionpulse/static/brand/session-pulse-idle-static.svg +42 -0
- sessionpulse/static/brand/session-pulse-none-animated.svg +52 -0
- sessionpulse/static/brand/session-pulse-none-static.svg +42 -0
- sessionpulse/static/brand/session-pulse-working-animated.svg +52 -0
- sessionpulse/static/brand/session-pulse-working-static.svg +42 -0
- sessionpulse/static/dashboard.html +927 -0
- sessionpulse/todos.py +163 -0
- sessionpulse/tray.py +162 -0
- sessionpulse-0.1.0.dist-info/METADATA +221 -0
- sessionpulse-0.1.0.dist-info/RECORD +29 -0
- sessionpulse-0.1.0.dist-info/WHEEL +5 -0
- sessionpulse-0.1.0.dist-info/entry_points.txt +2 -0
- sessionpulse-0.1.0.dist-info/licenses/LICENSE +21 -0
- sessionpulse-0.1.0.dist-info/top_level.txt +1 -0
sessionpulse/install.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Adds and removes Session Pulse's hooks in Claude Code's settings.json,
|
|
2
|
+
and its login autostart entry.
|
|
3
|
+
|
|
4
|
+
Our hook entries are recognised by HOOK_MARK in the command, so install is
|
|
5
|
+
idempotent (re-running updates the interpreter path) and uninstall removes
|
|
6
|
+
only what we added. settings.json is backed up before every change and
|
|
7
|
+
written atomically; if it isn't valid JSON we refuse to touch it.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .config import Config
|
|
17
|
+
|
|
18
|
+
HOOK_MARK = "-m sessionpulse hook"
|
|
19
|
+
EVENTS = ("SessionStart", "SessionEnd")
|
|
20
|
+
AUTOSTART = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "autostart" / "sessionpulse.desktop"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SettingsError(Exception):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def hook_command():
|
|
28
|
+
return f'"{sys.executable}" {HOOK_MARK}'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _settings_path(cfg: Config) -> Path:
|
|
32
|
+
return cfg.claude / "settings.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load(path: Path):
|
|
36
|
+
if not path.exists():
|
|
37
|
+
return {}
|
|
38
|
+
try:
|
|
39
|
+
data = json.loads(path.read_text())
|
|
40
|
+
except ValueError as exc:
|
|
41
|
+
raise SettingsError(f"{path} is not valid JSON ({exc}); not changing it.") from exc
|
|
42
|
+
if not isinstance(data, dict):
|
|
43
|
+
raise SettingsError(f"{path} is not a JSON object; not changing it.")
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _save(path: Path, data):
|
|
48
|
+
if path.exists():
|
|
49
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
50
|
+
shutil.copy2(path, path.with_name(f"{path.name}.sessionpulse-backup-{stamp}"))
|
|
51
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
tmp = path.with_name(path.name + ".sessionpulse-tmp")
|
|
53
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n")
|
|
54
|
+
os.replace(tmp, path)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ours(hook):
|
|
58
|
+
return isinstance(hook, dict) and HOOK_MARK in str(hook.get("command", ""))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def hooks_installed(cfg: Config):
|
|
62
|
+
data = _load(_settings_path(cfg))
|
|
63
|
+
found = set()
|
|
64
|
+
for ev in EVENTS:
|
|
65
|
+
for group in (data.get("hooks") or {}).get(ev) or []:
|
|
66
|
+
if any(_ours(h) for h in (group or {}).get("hooks") or []):
|
|
67
|
+
found.add(ev)
|
|
68
|
+
return found
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def install_hooks(cfg: Config):
|
|
72
|
+
path = _settings_path(cfg)
|
|
73
|
+
data = _load(path)
|
|
74
|
+
hooks = data.setdefault("hooks", {})
|
|
75
|
+
if not isinstance(hooks, dict):
|
|
76
|
+
raise SettingsError(f'"hooks" in {path} is not an object; not changing it.')
|
|
77
|
+
ours = {"type": "command", "command": hook_command(), "timeout": 10}
|
|
78
|
+
changed = False
|
|
79
|
+
for ev in EVENTS:
|
|
80
|
+
groups = hooks.setdefault(ev, [])
|
|
81
|
+
present = False
|
|
82
|
+
for group in groups:
|
|
83
|
+
for i, h in enumerate((group or {}).get("hooks") or []):
|
|
84
|
+
if _ours(h):
|
|
85
|
+
present = True
|
|
86
|
+
if h != ours:
|
|
87
|
+
group["hooks"][i] = dict(ours)
|
|
88
|
+
changed = True
|
|
89
|
+
if not present:
|
|
90
|
+
groups.append({"hooks": [dict(ours)]})
|
|
91
|
+
changed = True
|
|
92
|
+
if changed:
|
|
93
|
+
_save(path, data)
|
|
94
|
+
return changed
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def uninstall_hooks(cfg: Config):
|
|
98
|
+
path = _settings_path(cfg)
|
|
99
|
+
data = _load(path)
|
|
100
|
+
hooks = data.get("hooks")
|
|
101
|
+
if not isinstance(hooks, dict):
|
|
102
|
+
return False
|
|
103
|
+
changed = False
|
|
104
|
+
for ev in EVENTS:
|
|
105
|
+
groups = hooks.get(ev)
|
|
106
|
+
if not isinstance(groups, list):
|
|
107
|
+
continue
|
|
108
|
+
kept = []
|
|
109
|
+
for group in groups:
|
|
110
|
+
inner = (group or {}).get("hooks") or []
|
|
111
|
+
left = [h for h in inner if not _ours(h)]
|
|
112
|
+
if len(left) != len(inner):
|
|
113
|
+
changed = True
|
|
114
|
+
if not left:
|
|
115
|
+
continue
|
|
116
|
+
group = {**group, "hooks": left}
|
|
117
|
+
kept.append(group)
|
|
118
|
+
if kept:
|
|
119
|
+
hooks[ev] = kept
|
|
120
|
+
else:
|
|
121
|
+
del hooks[ev]
|
|
122
|
+
if not hooks:
|
|
123
|
+
data.pop("hooks")
|
|
124
|
+
if changed:
|
|
125
|
+
_save(path, data)
|
|
126
|
+
return changed
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def enable_autostart():
|
|
130
|
+
icon = Path(__file__).with_name("icons") / "sessionpulse-idle.svg"
|
|
131
|
+
AUTOSTART.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
AUTOSTART.write_text(
|
|
133
|
+
"[Desktop Entry]\n"
|
|
134
|
+
"Type=Application\n"
|
|
135
|
+
"Name=Session Pulse\n"
|
|
136
|
+
"Comment=Panel icon and dashboard for Claude Code sessions\n"
|
|
137
|
+
f'Exec="{sys.executable}" -m sessionpulse tray\n'
|
|
138
|
+
f"Icon={icon}\n"
|
|
139
|
+
"Terminal=false\n"
|
|
140
|
+
"X-GNOME-Autostart-enabled=true\n"
|
|
141
|
+
"X-GNOME-Autostart-Delay=5\n")
|
|
142
|
+
return AUTOSTART
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def disable_autostart():
|
|
146
|
+
if AUTOSTART.exists():
|
|
147
|
+
AUTOSTART.unlink()
|
|
148
|
+
return True
|
|
149
|
+
return False
|
sessionpulse/learn.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""The dashboard's "Get better at Claude Code" section.
|
|
2
|
+
|
|
3
|
+
LIBRARY is a short, hand-checked list of free, official places to learn.
|
|
4
|
+
suggest() looks at your own sessions and puts the most relevant items first,
|
|
5
|
+
with the reason taken from your data ("9 sessions ran past 40 prompts").
|
|
6
|
+
It only reads what the reader already parsed, plus whether CLAUDE.md exists in
|
|
7
|
+
your busiest project folders and whether settings.json has hooks of your own.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
DOCS = "https://code.claude.com/docs/en/"
|
|
14
|
+
|
|
15
|
+
# level: start = new to Claude Code, better = using it daily, advanced = extending it.
|
|
16
|
+
LIBRARY = [
|
|
17
|
+
{"id": "quickstart", "level": "start", "kind": "Docs", "title": "Quickstart",
|
|
18
|
+
"url": DOCS + "quickstart", "blurb": "Your first real task, step by step."},
|
|
19
|
+
{"id": "course-cc", "level": "better", "kind": "Course",
|
|
20
|
+
"title": "Claude Code in Action (Anthropic Academy)",
|
|
21
|
+
"url": "https://anthropic.skilljar.com/claude-code-in-action",
|
|
22
|
+
"blurb": "Free course for daily users: long sessions, CLAUDE.md and skills, automating repeat work."},
|
|
23
|
+
{"id": "best-practices", "level": "start", "kind": "Docs", "title": "Best practices for Claude Code",
|
|
24
|
+
"url": DOCS + "best-practices", "blurb": "How to give context, check work and keep sessions on track."},
|
|
25
|
+
{"id": "workflows", "level": "start", "kind": "Docs", "title": "Common workflows",
|
|
26
|
+
"url": DOCS + "common-workflows", "blurb": "Recipes: understand a codebase, fix bugs, refactor, write tests, open PRs."},
|
|
27
|
+
{"id": "eng-blog", "level": "better", "kind": "Article", "title": "Claude Code: best practices for agentic coding",
|
|
28
|
+
"url": "https://www.anthropic.com/engineering/claude-code-best-practices",
|
|
29
|
+
"blurb": "Anthropic's engineers on the habits that work: explore, plan, code, commit."},
|
|
30
|
+
{"id": "memory", "level": "better", "kind": "Docs", "title": "How Claude remembers your project (CLAUDE.md)",
|
|
31
|
+
"url": DOCS + "memory", "blurb": "Write project instructions once instead of repeating them every session."},
|
|
32
|
+
{"id": "context", "level": "better", "kind": "Docs", "title": "Explore the context window",
|
|
33
|
+
"url": DOCS + "context-window", "blurb": "What fills the context, and when to /clear or /compact."},
|
|
34
|
+
{"id": "costs", "level": "better", "kind": "Docs", "title": "Manage costs effectively",
|
|
35
|
+
"url": DOCS + "costs", "blurb": "Keep token use down without slowing yourself down."},
|
|
36
|
+
{"id": "plan-mode", "level": "better", "kind": "Docs", "title": "Permission modes, including plan mode",
|
|
37
|
+
"url": DOCS + "permission-modes", "blurb": "Have Claude plan and agree the approach before it edits anything."},
|
|
38
|
+
{"id": "prompting", "level": "better", "kind": "Docs", "title": "Prompt engineering overview",
|
|
39
|
+
"url": "https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/overview",
|
|
40
|
+
"blurb": "Be clear and specific, give examples, say what done looks like."},
|
|
41
|
+
{"id": "prompt-tutorial", "level": "better", "kind": "Tutorial", "title": "Interactive prompt engineering tutorial",
|
|
42
|
+
"url": "https://github.com/anthropics/prompt-eng-interactive-tutorial",
|
|
43
|
+
"blurb": "Nine hands-on chapters with exercises, from Anthropic."},
|
|
44
|
+
{"id": "subagents", "level": "advanced", "kind": "Docs", "title": "Create custom subagents",
|
|
45
|
+
"url": DOCS + "sub-agents", "blurb": "Hand research or review to a helper with its own context."},
|
|
46
|
+
{"id": "skills", "level": "advanced", "kind": "Docs", "title": "Extend Claude with skills",
|
|
47
|
+
"url": DOCS + "skills", "blurb": "Package a procedure you repeat so Claude follows it the same way every time."},
|
|
48
|
+
{"id": "course-skills", "level": "advanced", "kind": "Course",
|
|
49
|
+
"title": "Introduction to agent skills (Anthropic Academy)",
|
|
50
|
+
"url": "https://anthropic.skilljar.com/introduction-to-agent-skills",
|
|
51
|
+
"blurb": "Free course on writing and using skills."},
|
|
52
|
+
{"id": "mcp", "level": "advanced", "kind": "Docs", "title": "Connect Claude Code to tools via MCP",
|
|
53
|
+
"url": DOCS + "mcp", "blurb": "Let Claude use your issue tracker, database, browser or chat."},
|
|
54
|
+
{"id": "course-mcp", "level": "advanced", "kind": "Course",
|
|
55
|
+
"title": "Introduction to Model Context Protocol (Anthropic Academy)",
|
|
56
|
+
"url": "https://anthropic.skilljar.com/introduction-to-model-context-protocol",
|
|
57
|
+
"blurb": "Free course: how MCP servers work and how to build one."},
|
|
58
|
+
{"id": "hooks", "level": "advanced", "kind": "Docs", "title": "Automate actions with hooks",
|
|
59
|
+
"url": DOCS + "hooks-guide", "blurb": "Run formatters, checks or notifications automatically at set points."},
|
|
60
|
+
{"id": "worktrees", "level": "advanced", "kind": "Docs", "title": "Run parallel sessions with worktrees",
|
|
61
|
+
"url": DOCS + "worktrees", "blurb": "Work on several tasks at once without them touching each other."},
|
|
62
|
+
{"id": "headless", "level": "advanced", "kind": "Docs", "title": "Run Claude Code programmatically",
|
|
63
|
+
"url": DOCS + "headless", "blurb": "Use Claude Code from scripts, cron and CI."},
|
|
64
|
+
{"id": "whats-new", "level": "current", "kind": "Docs", "title": "What's new (weekly)",
|
|
65
|
+
"url": DOCS + "whats-new", "blurb": "A short weekly note on new features."},
|
|
66
|
+
{"id": "academy", "level": "current", "kind": "Course", "title": "All Anthropic Academy courses",
|
|
67
|
+
"url": "https://anthropic.skilljar.com/", "blurb": "Every free course, including the API and MCP tracks."},
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
WINDOW_DAYS = 30
|
|
71
|
+
MIN_SESSIONS = 5 # below this there isn't enough to judge habits
|
|
72
|
+
LONG_PROMPTS = 40 # a session past this many prompts is "long"
|
|
73
|
+
SHORT_WORDS = 4 # a first prompt this short rarely carries enough context
|
|
74
|
+
BUSIEST = 8 # how many projects to check for CLAUDE.md
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _has_memory(cwd):
|
|
78
|
+
p = Path(cwd)
|
|
79
|
+
return any((p / f).is_file() for f in ("CLAUDE.md", ".claude/CLAUDE.md", "CLAUDE.local.md"))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _own_hooks(cfg):
|
|
83
|
+
"""True/False if settings.json has hooks besides Session Pulse's; None if unreadable."""
|
|
84
|
+
try:
|
|
85
|
+
hooks = json.loads((cfg.claude / "settings.json").read_text()).get("hooks") or {}
|
|
86
|
+
except (OSError, ValueError, AttributeError):
|
|
87
|
+
return None
|
|
88
|
+
text = json.dumps(hooks)
|
|
89
|
+
return text.count('"command"') > text.count("-m sessionpulse hook")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def suggest(cfg, sessions, now=None):
|
|
93
|
+
now = now or datetime.now().astimezone()
|
|
94
|
+
cutoff = (now - timedelta(days=WINDOW_DAYS)).isoformat()
|
|
95
|
+
recent = [s for s in sessions if (s.get("end") or "") >= cutoff and s.get("prompts")]
|
|
96
|
+
out = {"window_days": WINDOW_DAYS, "sessions": len(recent), "suggestions": [],
|
|
97
|
+
"strengths": [], "library": LIBRARY}
|
|
98
|
+
if len(recent) < MIN_SESSIONS:
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
tools = {}
|
|
102
|
+
for s in sessions: # feature use counts over all history: one use is enough to know it
|
|
103
|
+
for k, v in (s.get("tool_names") or {}).items():
|
|
104
|
+
tools[k] = tools.get(k, 0) + v
|
|
105
|
+
agents = sum(s.get("agents") or 0 for s in sessions)
|
|
106
|
+
|
|
107
|
+
def add(sid, title, why, links):
|
|
108
|
+
out["suggestions"].append({"id": sid, "title": title, "why": why, "links": links})
|
|
109
|
+
|
|
110
|
+
hours = {}
|
|
111
|
+
for s in recent:
|
|
112
|
+
if s.get("cwd"):
|
|
113
|
+
hours[s["cwd"]] = hours.get(s["cwd"], 0) + (s.get("hours") or 0)
|
|
114
|
+
busiest = sorted(hours, key=hours.get, reverse=True)[:BUSIEST]
|
|
115
|
+
missing = [s for s in busiest if Path(s) != Path.home() and Path(s).is_dir() and not _has_memory(s)]
|
|
116
|
+
if missing:
|
|
117
|
+
names = ", ".join(Path(m).name for m in missing[:4]) + (" …" if len(missing) > 4 else "")
|
|
118
|
+
add("memory", "Give your projects a CLAUDE.md",
|
|
119
|
+
f"{len(missing)} of your {len(busiest)} busiest projects have none ({names}). "
|
|
120
|
+
"Run /init in one to start it.", ["memory", "best-practices"])
|
|
121
|
+
else:
|
|
122
|
+
out["strengths"].append("Your busiest projects have a CLAUDE.md")
|
|
123
|
+
|
|
124
|
+
long_ = [s for s in recent if (s.get("prompts") or 0) >= LONG_PROMPTS]
|
|
125
|
+
if len(long_) >= 2:
|
|
126
|
+
add("context", "Keep sessions short and focused",
|
|
127
|
+
f"{len(long_)} sessions in the last {WINDOW_DAYS} days ran past {LONG_PROMPTS} prompts. "
|
|
128
|
+
"Long sessions fill the context, which costs tokens and makes earlier details easier to lose. "
|
|
129
|
+
"Use /clear between unrelated tasks.", ["context", "costs", "course-cc"])
|
|
130
|
+
|
|
131
|
+
short = [s for s in recent if 0 < len((s.get("first_prompt") or "").split()) <= SHORT_WORDS]
|
|
132
|
+
if len(short) >= 3 and len(short) / len(recent) >= 0.25:
|
|
133
|
+
add("prompting", "Open with more context",
|
|
134
|
+
f"{len(short)} of {len(recent)} recent sessions started with {SHORT_WORDS} words or fewer. "
|
|
135
|
+
"Say what you want, where it is and how you'll judge it done.",
|
|
136
|
+
["prompting", "prompt-tutorial", "eng-blog"])
|
|
137
|
+
|
|
138
|
+
def feature(used, sid, title, why, links, strength):
|
|
139
|
+
if used:
|
|
140
|
+
out["strengths"].append(strength)
|
|
141
|
+
else:
|
|
142
|
+
add(sid, title, why, links)
|
|
143
|
+
|
|
144
|
+
feature(tools.get("ExitPlanMode") or tools.get("EnterPlanMode"), "plan-mode", "Try plan mode on bigger changes",
|
|
145
|
+
"None of your sessions used plan mode. Press Shift+Tab until it shows plan mode, and Claude "
|
|
146
|
+
"proposes a plan before it touches files.", ["plan-mode", "workflows"], "You use plan mode")
|
|
147
|
+
feature(agents or tools.get("Agent") or tools.get("Task"), "subagents", "Hand side-work to subagents",
|
|
148
|
+
"No session used a subagent yet. They do research or reviews in their own context, "
|
|
149
|
+
"so your main session stays clean.", ["subagents"], "You use subagents")
|
|
150
|
+
feature(tools.get("Skill"), "skills", "Turn repeated instructions into a skill",
|
|
151
|
+
"No session used a skill yet.", ["skills", "course-skills"], "You use skills")
|
|
152
|
+
feature(any(k.startswith("mcp__") for k in tools), "mcp", "Connect the tools you work in",
|
|
153
|
+
"No session used an MCP server yet.", ["mcp", "course-mcp"], "You connect tools through MCP")
|
|
154
|
+
own = _own_hooks(cfg)
|
|
155
|
+
if own is not None:
|
|
156
|
+
feature(own, "hooks", "Automate the routine with hooks",
|
|
157
|
+
"Your settings have no hooks of your own yet.", ["hooks"], "You automate with hooks")
|
|
158
|
+
return out
|
sessionpulse/reader.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Reads Claude Code's own files and turns them into one JSON-able snapshot.
|
|
2
|
+
|
|
3
|
+
Sources (all read-only):
|
|
4
|
+
<claude_dir>/sessions/*.json one file per running CLI process
|
|
5
|
+
<claude_dir>/projects/*/*.jsonl session transcripts
|
|
6
|
+
<claude_dir>/projects/*/<id>/subagents/*.jsonl subagent transcripts
|
|
7
|
+
<data_dir>/events.jsonl start/exit events written by our hook
|
|
8
|
+
|
|
9
|
+
None of these formats is documented by Anthropic, so every field is read
|
|
10
|
+
defensively and anything surprising is counted in snapshot["health"] instead
|
|
11
|
+
of raising. The dashboard shows those warnings so a Claude Code update that
|
|
12
|
+
changes a format is visible rather than silently producing wrong numbers.
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import threading
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from datetime import datetime, timedelta
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import learn, todos
|
|
23
|
+
from .config import Config
|
|
24
|
+
|
|
25
|
+
TESTED_CLI = (2, 1) # newest Claude Code minor version these parsers were checked against
|
|
26
|
+
LIVE_KEYS = ("pid", "sessionId", "status")
|
|
27
|
+
|
|
28
|
+
_cache = {} # path -> (mtime_ns, size, summary)
|
|
29
|
+
lock = threading.Lock()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def local(s):
|
|
33
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def text_of(content):
|
|
37
|
+
if isinstance(content, str):
|
|
38
|
+
return content
|
|
39
|
+
if isinstance(content, list):
|
|
40
|
+
return "\n".join(c.get("text", "") for c in content
|
|
41
|
+
if isinstance(c, dict) and c.get("type") == "text")
|
|
42
|
+
return ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_noise(t):
|
|
46
|
+
"""Messages the CLI injects (command output, skill text, interrupts) rather than prompts."""
|
|
47
|
+
t = t.strip()
|
|
48
|
+
return (not t or t.startswith("<") or t.startswith("Caveat:")
|
|
49
|
+
or t.startswith("Base directory for this skill")
|
|
50
|
+
or "[Request interrupted" in t)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def active_hours(stamps, gap=timedelta(minutes=30), min_block=timedelta(minutes=15)):
|
|
54
|
+
if not stamps:
|
|
55
|
+
return 0.0
|
|
56
|
+
stamps = sorted(stamps)
|
|
57
|
+
total, start, prev = timedelta(), stamps[0], stamps[0]
|
|
58
|
+
for t in stamps[1:]:
|
|
59
|
+
if t - prev > gap:
|
|
60
|
+
total += max(prev - start, min_block)
|
|
61
|
+
start = t
|
|
62
|
+
prev = t
|
|
63
|
+
total += max(prev - start, min_block)
|
|
64
|
+
return total.total_seconds() / 3600
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def project_name(cwd):
|
|
68
|
+
if not cwd:
|
|
69
|
+
return "?"
|
|
70
|
+
home = str(Path.home())
|
|
71
|
+
if cwd == home:
|
|
72
|
+
return "~"
|
|
73
|
+
if cwd.startswith(home + os.sep):
|
|
74
|
+
return os.path.relpath(cwd, home).replace(os.sep, "/")
|
|
75
|
+
return cwd
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_transcript(path, main=True):
|
|
79
|
+
"""Summarise one transcript file.
|
|
80
|
+
|
|
81
|
+
Token usage is de-duplicated by message id: the CLI writes one line per
|
|
82
|
+
content block, and each line repeats the same usage object."""
|
|
83
|
+
s = {"cwd": None, "first": None, "last": None, "prompts": 0, "first_prompt": "",
|
|
84
|
+
"last_prompt": "", "models": defaultdict(int), "version": None, "title": None,
|
|
85
|
+
"usage": defaultdict(int), "days": defaultdict(lambda: defaultdict(int)),
|
|
86
|
+
"stamps": [], "tools": 0, "tool_names": defaultdict(int), "bad_lines": 0, "lines": 0, "usage_msgs": 0}
|
|
87
|
+
seen = set()
|
|
88
|
+
with open(path, errors="replace") as f:
|
|
89
|
+
for line in f:
|
|
90
|
+
s["lines"] += 1
|
|
91
|
+
try:
|
|
92
|
+
e = json.loads(line)
|
|
93
|
+
except ValueError:
|
|
94
|
+
s["bad_lines"] += 1
|
|
95
|
+
continue
|
|
96
|
+
if not isinstance(e, dict):
|
|
97
|
+
s["bad_lines"] += 1
|
|
98
|
+
continue
|
|
99
|
+
typ = e.get("type")
|
|
100
|
+
if main and typ in ("custom-title", "summary"):
|
|
101
|
+
s["title"] = e.get("customTitle") or e.get("summary") or s["title"]
|
|
102
|
+
if s["cwd"] is None and isinstance(e.get("cwd"), str):
|
|
103
|
+
s["cwd"] = e["cwd"]
|
|
104
|
+
v = e.get("version")
|
|
105
|
+
if isinstance(v, str) and (_version_tuple(v) or ()) >= (_version_tuple(s["version"]) or ()):
|
|
106
|
+
s["version"] = v # a resumed session can span CLI upgrades; keep the newest
|
|
107
|
+
try:
|
|
108
|
+
t = local(e["timestamp"])
|
|
109
|
+
except (KeyError, TypeError, ValueError, AttributeError):
|
|
110
|
+
continue
|
|
111
|
+
s["first"] = min(s["first"] or t, t)
|
|
112
|
+
s["last"] = max(s["last"] or t, t)
|
|
113
|
+
msg = e.get("message") if isinstance(e.get("message"), dict) else {}
|
|
114
|
+
if main and not e.get("isSidechain"):
|
|
115
|
+
s["stamps"].append(t)
|
|
116
|
+
if typ == "user" and msg.get("role") == "user":
|
|
117
|
+
body = text_of(msg.get("content"))
|
|
118
|
+
if not is_noise(body):
|
|
119
|
+
body = re.sub(r"\s+", " ", body).strip()
|
|
120
|
+
s["prompts"] += 1
|
|
121
|
+
s["first_prompt"] = s["first_prompt"] or body[:300]
|
|
122
|
+
s["last_prompt"] = body[:300]
|
|
123
|
+
if typ != "assistant":
|
|
124
|
+
continue
|
|
125
|
+
content = msg.get("content")
|
|
126
|
+
for c in content if isinstance(content, list) else []:
|
|
127
|
+
if isinstance(c, dict) and c.get("type") == "tool_use":
|
|
128
|
+
s["tools"] += 1
|
|
129
|
+
if isinstance(c.get("name"), str):
|
|
130
|
+
s["tool_names"][c["name"]] += 1
|
|
131
|
+
u = msg.get("usage")
|
|
132
|
+
key = msg.get("id") or e.get("requestId") or e.get("uuid")
|
|
133
|
+
if not isinstance(u, dict) or key in seen:
|
|
134
|
+
continue
|
|
135
|
+
seen.add(key)
|
|
136
|
+
model = msg.get("model") or "unknown"
|
|
137
|
+
if model.startswith("<"): # synthetic messages, e.g. "<synthetic>"
|
|
138
|
+
continue
|
|
139
|
+
s["usage_msgs"] += 1
|
|
140
|
+
vals = {"input": u.get("input_tokens") or 0,
|
|
141
|
+
"output": u.get("output_tokens") or 0,
|
|
142
|
+
"cache_write": u.get("cache_creation_input_tokens") or 0,
|
|
143
|
+
"cache_read": u.get("cache_read_input_tokens") or 0}
|
|
144
|
+
s["models"][model] += vals["input"] + vals["output"]
|
|
145
|
+
day = t.date().isoformat()
|
|
146
|
+
for k, v in vals.items():
|
|
147
|
+
if isinstance(v, (int, float)):
|
|
148
|
+
s["usage"][k] += v
|
|
149
|
+
s["days"][day][f"{model}|{k}"] += v
|
|
150
|
+
return s
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _cached(path, main=True):
|
|
154
|
+
try:
|
|
155
|
+
st = os.stat(path)
|
|
156
|
+
except OSError:
|
|
157
|
+
return None
|
|
158
|
+
hit = _cache.get(path)
|
|
159
|
+
if hit and hit[0] == st.st_mtime_ns and hit[1] == st.st_size:
|
|
160
|
+
return hit[2]
|
|
161
|
+
try:
|
|
162
|
+
summ = parse_transcript(path, main)
|
|
163
|
+
except OSError:
|
|
164
|
+
return None
|
|
165
|
+
_cache[path] = (st.st_mtime_ns, st.st_size, summ)
|
|
166
|
+
return summ
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def pid_alive(pid):
|
|
170
|
+
if not isinstance(pid, int) or pid <= 0:
|
|
171
|
+
return False
|
|
172
|
+
try:
|
|
173
|
+
os.kill(pid, 0)
|
|
174
|
+
return True
|
|
175
|
+
except PermissionError:
|
|
176
|
+
return True
|
|
177
|
+
except OSError:
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def read_live(claude: Path, health):
|
|
182
|
+
live = {}
|
|
183
|
+
d = claude / "sessions"
|
|
184
|
+
if not d.is_dir():
|
|
185
|
+
health["warnings"].append(f"{d} not found: live sessions can't be shown "
|
|
186
|
+
"(older Claude Code, or a changed layout).")
|
|
187
|
+
return live
|
|
188
|
+
for p in d.glob("*.json"):
|
|
189
|
+
try:
|
|
190
|
+
j = json.loads(p.read_text())
|
|
191
|
+
except (OSError, ValueError):
|
|
192
|
+
continue
|
|
193
|
+
if not isinstance(j, dict) or any(k not in j for k in LIVE_KEYS):
|
|
194
|
+
health["live_unrecognised"] += 1
|
|
195
|
+
continue
|
|
196
|
+
if not pid_alive(j.get("pid")):
|
|
197
|
+
continue
|
|
198
|
+
live[j["sessionId"]] = {
|
|
199
|
+
"pid": j.get("pid"), "status": j.get("status"), "name": j.get("name"),
|
|
200
|
+
"kind": j.get("kind"), "entrypoint": j.get("entrypoint"),
|
|
201
|
+
"startedAt": j.get("startedAt"),
|
|
202
|
+
"updatedAt": j.get("statusUpdatedAt") or j.get("updatedAt"),
|
|
203
|
+
}
|
|
204
|
+
return live
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def read_events(path: Path):
|
|
208
|
+
out = []
|
|
209
|
+
if path.exists():
|
|
210
|
+
with open(path, errors="replace") as f:
|
|
211
|
+
for line in f:
|
|
212
|
+
try:
|
|
213
|
+
e = json.loads(line)
|
|
214
|
+
except ValueError:
|
|
215
|
+
continue
|
|
216
|
+
if isinstance(e, dict) and e.get("ts"):
|
|
217
|
+
out.append(e)
|
|
218
|
+
return out
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _version_tuple(v):
|
|
222
|
+
try:
|
|
223
|
+
return tuple(int(x) for x in v.split("."))
|
|
224
|
+
except (AttributeError, ValueError):
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def snapshot(cfg: Config):
|
|
229
|
+
claude = cfg.claude
|
|
230
|
+
gap = timedelta(minutes=cfg.gap_minutes)
|
|
231
|
+
min_block = timedelta(minutes=cfg.min_block_minutes)
|
|
232
|
+
health = {"warnings": [], "files": 0, "bad_lines": 0, "lines": 0,
|
|
233
|
+
"live_unrecognised": 0, "newest_cli": None}
|
|
234
|
+
sessions = {}
|
|
235
|
+
proj_dir = claude / "projects"
|
|
236
|
+
if not proj_dir.is_dir():
|
|
237
|
+
health["warnings"].append(f"No transcripts at {proj_dir}. Is claude_dir right?")
|
|
238
|
+
newest = None
|
|
239
|
+
usage_msgs = 0
|
|
240
|
+
for pdir in proj_dir.iterdir() if proj_dir.is_dir() else []:
|
|
241
|
+
if not pdir.is_dir():
|
|
242
|
+
continue
|
|
243
|
+
for tf in pdir.glob("*.jsonl"):
|
|
244
|
+
sid = tf.stem
|
|
245
|
+
s = _cached(str(tf))
|
|
246
|
+
if not s:
|
|
247
|
+
continue
|
|
248
|
+
health["files"] += 1
|
|
249
|
+
health["bad_lines"] += s["bad_lines"]
|
|
250
|
+
health["lines"] += s["lines"]
|
|
251
|
+
usage_msgs += s["usage_msgs"]
|
|
252
|
+
if not s["first"]:
|
|
253
|
+
continue
|
|
254
|
+
if s["version"] and (newest is None or
|
|
255
|
+
(_version_tuple(s["version"]) or ()) > (_version_tuple(newest) or ())):
|
|
256
|
+
newest = s["version"]
|
|
257
|
+
usage = dict(s["usage"])
|
|
258
|
+
days = {d: dict(v) for d, v in s["days"].items()}
|
|
259
|
+
models = dict(s["models"])
|
|
260
|
+
tool_names = dict(s["tool_names"])
|
|
261
|
+
agents = 0
|
|
262
|
+
for af in (pdir / sid / "subagents").glob("*.jsonl"):
|
|
263
|
+
a = _cached(str(af), main=False)
|
|
264
|
+
if not a:
|
|
265
|
+
continue
|
|
266
|
+
agents += 1
|
|
267
|
+
for k, v in a["usage"].items():
|
|
268
|
+
usage[k] = usage.get(k, 0) + v
|
|
269
|
+
for m, v in a["models"].items():
|
|
270
|
+
models[m] = models.get(m, 0) + v
|
|
271
|
+
for d, kv in a["days"].items():
|
|
272
|
+
dd = days.setdefault(d, {})
|
|
273
|
+
for k, v in kv.items():
|
|
274
|
+
dd[k] = dd.get(k, 0) + v
|
|
275
|
+
if not s["prompts"] and not usage.get("output"):
|
|
276
|
+
continue
|
|
277
|
+
by_day = defaultdict(list)
|
|
278
|
+
for t in s["stamps"]:
|
|
279
|
+
by_day[t.date().isoformat()].append(t)
|
|
280
|
+
sessions[sid] = {
|
|
281
|
+
"id": sid, "cwd": s["cwd"], "project": project_name(s["cwd"]), "transcript": str(tf),
|
|
282
|
+
"title": s["title"], "start": s["first"].isoformat(), "end": s["last"].isoformat(),
|
|
283
|
+
"prompts": s["prompts"], "first_prompt": s["first_prompt"],
|
|
284
|
+
"last_prompt": s["last_prompt"], "tools": s["tools"], "tool_names": tool_names,
|
|
285
|
+
"agents": agents,
|
|
286
|
+
"version": s["version"], "usage": usage, "days": days,
|
|
287
|
+
"model": max(models, key=models.get) if models else None,
|
|
288
|
+
"hours": round(active_hours(s["stamps"], gap, min_block), 2),
|
|
289
|
+
"hours_by_day": {d: round(active_hours(v, gap, min_block), 2)
|
|
290
|
+
for d, v in by_day.items()},
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
health["newest_cli"] = newest
|
|
294
|
+
if health["lines"] and health["bad_lines"] / health["lines"] > 0.05:
|
|
295
|
+
health["warnings"].append(f"{health['bad_lines']} of {health['lines']} transcript lines "
|
|
296
|
+
"could not be read; the transcript format may have changed.")
|
|
297
|
+
if health["files"] and sessions and not usage_msgs:
|
|
298
|
+
health["warnings"].append("No token usage found in any transcript; "
|
|
299
|
+
"the usage format may have changed.")
|
|
300
|
+
if health["live_unrecognised"]:
|
|
301
|
+
health["warnings"].append(f"{health['live_unrecognised']} live-session files had an "
|
|
302
|
+
"unexpected shape and were skipped.")
|
|
303
|
+
vt = _version_tuple(newest)
|
|
304
|
+
if vt and vt[:2] > TESTED_CLI:
|
|
305
|
+
health["warnings"].append(f"Claude Code {newest} is newer than the version Session Pulse "
|
|
306
|
+
f"was tested with ({'.'.join(map(str, TESTED_CLI))}). "
|
|
307
|
+
"Check the numbers look right.")
|
|
308
|
+
|
|
309
|
+
live = read_live(claude, health)
|
|
310
|
+
events = read_events(cfg.events_file)
|
|
311
|
+
exits = {e.get("session"): e for e in events if e.get("event") == "SessionEnd"}
|
|
312
|
+
for sid, s in sessions.items():
|
|
313
|
+
if sid in live:
|
|
314
|
+
s["state"] = live[sid]["status"] or "running"
|
|
315
|
+
s["live"] = live[sid]
|
|
316
|
+
elif sid in exits:
|
|
317
|
+
s["state"] = "exited"
|
|
318
|
+
s["exit_reason"] = exits[sid].get("reason")
|
|
319
|
+
s["exit_at"] = exits[sid].get("ts")
|
|
320
|
+
else:
|
|
321
|
+
s["state"] = "closed"
|
|
322
|
+
for sid, l in live.items(): # just started; no transcript written yet
|
|
323
|
+
if sid not in sessions:
|
|
324
|
+
sessions[sid] = {"id": sid, "project": "?", "state": l["status"] or "running",
|
|
325
|
+
"live": l, "start": None, "end": None, "prompts": 0,
|
|
326
|
+
"usage": {}, "days": {}, "hours": 0, "hours_by_day": {}}
|
|
327
|
+
for e in events:
|
|
328
|
+
s = sessions.get(e.get("session"))
|
|
329
|
+
e["project"] = s["project"] if s else project_name(e.get("cwd"))
|
|
330
|
+
sessions = list(sessions.values())
|
|
331
|
+
return {"now": datetime.now().astimezone().isoformat(), "refresh": cfg.refresh_seconds,
|
|
332
|
+
"sessions": sessions, "events": events[-300:], "health": health,
|
|
333
|
+
"todos": todos.load(cfg), "learn": learn.suggest(cfg, sessions)}
|