sessionpulse 0.1.2__tar.gz → 0.1.3__tar.gz
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-0.1.2/src/sessionpulse.egg-info → sessionpulse-0.1.3}/PKG-INFO +24 -1
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/README.md +23 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/pyproject.toml +1 -1
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/__init__.py +1 -1
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/cli.py +78 -4
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/followups.py +44 -6
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/install.py +3 -2
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/static/dashboard.html +26 -17
- sessionpulse-0.1.3/src/sessionpulse/status.py +96 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/todos.py +86 -5
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/tray.py +2 -2
- {sessionpulse-0.1.2 → sessionpulse-0.1.3/src/sessionpulse.egg-info}/PKG-INFO +24 -1
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse.egg-info/SOURCES.txt +1 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/tests/test_sessionpulse.py +109 -1
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/LICENSE +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/setup.cfg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/__main__.py +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/config.py +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/hook.py +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/icons/sessionpulse-busy.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/icons/sessionpulse-idle.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/icons/sessionpulse-none.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/learn.py +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/reader.py +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/server.py +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/static/brand/favicon.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/static/brand/session-pulse-idle-animated.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/static/brand/session-pulse-idle-static.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/static/brand/session-pulse-none-animated.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/static/brand/session-pulse-none-static.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/static/brand/session-pulse-working-animated.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse/static/brand/session-pulse-working-static.svg +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse.egg-info/dependency_links.txt +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse.egg-info/entry_points.txt +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse.egg-info/requires.txt +0 -0
- {sessionpulse-0.1.2 → sessionpulse-0.1.3}/src/sessionpulse.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sessionpulse
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: A quiet instrument panel for Claude Code: live sessions, usage, and a to-do board that fills itself from your sessions. Local only.
|
|
5
5
|
Author: Vijay Shukla
|
|
6
6
|
License-Expression: MIT
|
|
@@ -154,6 +154,27 @@ sessionpulse followups # read the last 30 days now instead of waiting
|
|
|
154
154
|
|
|
155
155
|
Check everything with `sessionpulse doctor`. Remove it with `sessionpulse uninstall` (your data stays) and `pipx uninstall sessionpulse`.
|
|
156
156
|
|
|
157
|
+
## GNOME extension
|
|
158
|
+
|
|
159
|
+
A native top-bar indicator for GNOME Shell 45 to 48, as an alternative to the AppIndicator panel icon. It shows the same thing: the number of open sessions, orange while one is working, plus a menu with each session, today's totals, and links to the dashboard and the to-do board.
|
|
160
|
+
|
|
161
|
+
The extension only reads `~/.local/share/sessionpulse/status.json` ([format](https://github.com/vijayshuklaHL/sessionpulse/blob/main/docs/status-json.md)), which `sessionpulse daemon` writes, so the app has to be installed too:
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
pipx install sessionpulse
|
|
165
|
+
sessionpulse install --daemon # hooks, plus the daemon (no icon) at login
|
|
166
|
+
sessionpulse daemon & # start it now
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Then install the extension. It isn't on extensions.gnome.org yet, so install it from a clone of this repo:
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
gnome-extension/pack.sh
|
|
173
|
+
gnome-extensions install --force gnome-extension/dist/sessionpulse@vijayshuklaHL.github.io.shell-extension.zip
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Log out and back in (on X11 you can press Alt+F2 and type `r` instead), then run `gnome-extensions enable sessionpulse@vijayshuklaHL.github.io`. If you used the panel icon before, stop it: run `sessionpulse install --daemon` again so login starts the daemon instead of the tray.
|
|
177
|
+
|
|
157
178
|
## Commands
|
|
158
179
|
|
|
159
180
|
| Command | What it does |
|
|
@@ -162,10 +183,12 @@ Check everything with `sessionpulse doctor`. Remove it with `sessionpulse uninst
|
|
|
162
183
|
| `sessionpulse uninstall` | Removes exactly those hooks and the autostart entry. Your data stays. |
|
|
163
184
|
| `sessionpulse tray` | Panel icon and dashboard server |
|
|
164
185
|
| `sessionpulse serve` | Dashboard server only |
|
|
186
|
+
| `sessionpulse daemon` | Dashboard server plus `status.json` for panel widgets such as the GNOME extension, no icon (`install --daemon` starts it at login) |
|
|
165
187
|
| `sessionpulse open` | Opens the dashboard in your browser, signed in (add `#board` to the address for the board) |
|
|
166
188
|
| `sessionpulse doctor` | Checks the setup and warns if Claude Code's file formats look different |
|
|
167
189
|
| `sessionpulse todo [add TEXT -p PROJECT \| done ID \| undo ID \| delete ID \| list-done]` | Your to-do list from the terminal |
|
|
168
190
|
| `sessionpulse followups [--days N] [--session ID] [--now] [--again]` | Read recent sessions now and add their open follow-ups |
|
|
191
|
+
| `sessionpulse dedupe [-p PROJECT] [--apply]` | Finds open to-dos that are the same work reworded; `--apply` archives the copies |
|
|
169
192
|
| `sessionpulse snapshot` | Prints the dashboard data as JSON |
|
|
170
193
|
|
|
171
194
|
## Settings
|
|
@@ -126,6 +126,27 @@ sessionpulse followups # read the last 30 days now instead of waiting
|
|
|
126
126
|
|
|
127
127
|
Check everything with `sessionpulse doctor`. Remove it with `sessionpulse uninstall` (your data stays) and `pipx uninstall sessionpulse`.
|
|
128
128
|
|
|
129
|
+
## GNOME extension
|
|
130
|
+
|
|
131
|
+
A native top-bar indicator for GNOME Shell 45 to 48, as an alternative to the AppIndicator panel icon. It shows the same thing: the number of open sessions, orange while one is working, plus a menu with each session, today's totals, and links to the dashboard and the to-do board.
|
|
132
|
+
|
|
133
|
+
The extension only reads `~/.local/share/sessionpulse/status.json` ([format](https://github.com/vijayshuklaHL/sessionpulse/blob/main/docs/status-json.md)), which `sessionpulse daemon` writes, so the app has to be installed too:
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
pipx install sessionpulse
|
|
137
|
+
sessionpulse install --daemon # hooks, plus the daemon (no icon) at login
|
|
138
|
+
sessionpulse daemon & # start it now
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Then install the extension. It isn't on extensions.gnome.org yet, so install it from a clone of this repo:
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
gnome-extension/pack.sh
|
|
145
|
+
gnome-extensions install --force gnome-extension/dist/sessionpulse@vijayshuklaHL.github.io.shell-extension.zip
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Log out and back in (on X11 you can press Alt+F2 and type `r` instead), then run `gnome-extensions enable sessionpulse@vijayshuklaHL.github.io`. If you used the panel icon before, stop it: run `sessionpulse install --daemon` again so login starts the daemon instead of the tray.
|
|
149
|
+
|
|
129
150
|
## Commands
|
|
130
151
|
|
|
131
152
|
| Command | What it does |
|
|
@@ -134,10 +155,12 @@ Check everything with `sessionpulse doctor`. Remove it with `sessionpulse uninst
|
|
|
134
155
|
| `sessionpulse uninstall` | Removes exactly those hooks and the autostart entry. Your data stays. |
|
|
135
156
|
| `sessionpulse tray` | Panel icon and dashboard server |
|
|
136
157
|
| `sessionpulse serve` | Dashboard server only |
|
|
158
|
+
| `sessionpulse daemon` | Dashboard server plus `status.json` for panel widgets such as the GNOME extension, no icon (`install --daemon` starts it at login) |
|
|
137
159
|
| `sessionpulse open` | Opens the dashboard in your browser, signed in (add `#board` to the address for the board) |
|
|
138
160
|
| `sessionpulse doctor` | Checks the setup and warns if Claude Code's file formats look different |
|
|
139
161
|
| `sessionpulse todo [add TEXT -p PROJECT \| done ID \| undo ID \| delete ID \| list-done]` | Your to-do list from the terminal |
|
|
140
162
|
| `sessionpulse followups [--days N] [--session ID] [--now] [--again]` | Read recent sessions now and add their open follow-ups |
|
|
163
|
+
| `sessionpulse dedupe [-p PROJECT] [--apply]` | Finds open to-dos that are the same work reworded; `--apply` archives the copies |
|
|
141
164
|
| `sessionpulse snapshot` | Prints the dashboard data as JSON |
|
|
142
165
|
|
|
143
166
|
## Settings
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sessionpulse"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "A quiet instrument panel for Claude Code: live sessions, usage, and a to-do board that fills itself from your sessions. Local only."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -3,11 +3,15 @@ import argparse
|
|
|
3
3
|
import getpass
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
+
import signal
|
|
6
7
|
import socket
|
|
8
|
+
import subprocess
|
|
7
9
|
import sys
|
|
10
|
+
import threading
|
|
8
11
|
import webbrowser
|
|
12
|
+
from collections import Counter
|
|
9
13
|
|
|
10
|
-
from . import __version__, config, followups, hook, install, reader, server, todos, tray
|
|
14
|
+
from . import __version__, config, followups, hook, install, reader, server, status, todos, tray
|
|
11
15
|
|
|
12
16
|
|
|
13
17
|
def headless():
|
|
@@ -37,6 +41,24 @@ def cmd_serve(cfg, args):
|
|
|
37
41
|
pass
|
|
38
42
|
|
|
39
43
|
|
|
44
|
+
def cmd_daemon(cfg, args):
|
|
45
|
+
"""Dashboard server + follow-ups + status.json, no icon: for the GNOME extension."""
|
|
46
|
+
try:
|
|
47
|
+
srv = server.make_server(cfg)
|
|
48
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
49
|
+
followups.start_worker(cfg)
|
|
50
|
+
print(f"Session Pulse on {cfg.url}", flush=True)
|
|
51
|
+
except OSError:
|
|
52
|
+
print(f"Port {cfg.port} is already in use (another `sessionpulse serve`/`tray`?); "
|
|
53
|
+
"writing status.json only.", flush=True)
|
|
54
|
+
print(f"Writing {status.path(cfg)} every {status.INTERVAL}s", flush=True)
|
|
55
|
+
stop = threading.Event()
|
|
56
|
+
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
|
|
57
|
+
signal.signal(sig, lambda *_: stop.set())
|
|
58
|
+
status.run(cfg, stop, on_error=lambda exc: print(f"status.json not written: {exc}",
|
|
59
|
+
file=sys.stderr, flush=True))
|
|
60
|
+
|
|
61
|
+
|
|
40
62
|
def cmd_tray(cfg, args):
|
|
41
63
|
tray.run(cfg)
|
|
42
64
|
|
|
@@ -62,6 +84,10 @@ def cmd_install(cfg, args):
|
|
|
62
84
|
f" SessionStart/SessionEnd hooks in {cfg.claude / 'settings.json'}")
|
|
63
85
|
if args.no_autostart:
|
|
64
86
|
return
|
|
87
|
+
if args.daemon:
|
|
88
|
+
print(f"Background daemon will start at login ({install.enable_autostart('daemon')}).")
|
|
89
|
+
print("Start it now with: sessionpulse daemon &")
|
|
90
|
+
return
|
|
65
91
|
missing = tray.available()
|
|
66
92
|
if missing:
|
|
67
93
|
print(f"Skipped login autostart: {missing}\nYou can still run: sessionpulse serve")
|
|
@@ -113,6 +139,11 @@ def cmd_doctor(cfg, args):
|
|
|
113
139
|
busy = s.connect_ex(("127.0.0.1", cfg.port)) == 0
|
|
114
140
|
print(f" {'ok ' if busy else '-- '} dashboard {'running' if busy else 'not running'} on {cfg.url}"
|
|
115
141
|
+ ("" if busy else ": start it with `sessionpulse serve`"))
|
|
142
|
+
a = status.age(cfg)
|
|
143
|
+
if a is not None:
|
|
144
|
+
fresh = a < status.INTERVAL * 3
|
|
145
|
+
print(f" {'ok ' if fresh else '-- '} status.json {'updated' if fresh else 'stale, last updated'} "
|
|
146
|
+
f"{int(a)}s ago{'' if fresh else ': is `sessionpulse daemon` running?'}")
|
|
116
147
|
for w in h["warnings"]:
|
|
117
148
|
line(False, w)
|
|
118
149
|
return 0 if ok else 1
|
|
@@ -132,11 +163,16 @@ def cmd_todo(cfg, args):
|
|
|
132
163
|
except ValueError as exc:
|
|
133
164
|
sys.exit(f"error: {exc}")
|
|
134
165
|
items = todos.load(cfg)
|
|
135
|
-
|
|
166
|
+
want = (lambda i: i["done_at"]) if args.action == "list-done" else todos.is_open
|
|
167
|
+
show = [i for i in items if want(i)]
|
|
136
168
|
for i in show:
|
|
137
169
|
tag = f" [{i['project']}]" if i.get("project") else ""
|
|
138
170
|
when = f" done {i['done_at'][:10]}" if i["done_at"] else ""
|
|
139
171
|
print(f"{i['id']} {i['text']}{tag}{when}")
|
|
172
|
+
parked = [i for i in items if not i["done_at"] and i.get("status") in todos.STATUSES]
|
|
173
|
+
if parked and args.action != "list-done":
|
|
174
|
+
held = sum(i["status"] == "hold" for i in parked)
|
|
175
|
+
print(f"({held} on hold, {len(parked) - held} archived; see the dashboard board)")
|
|
140
176
|
if not show:
|
|
141
177
|
print("Nothing here." if args.action == "list-done" else "No open to-dos.")
|
|
142
178
|
|
|
@@ -155,15 +191,50 @@ def cmd_followups(cfg, args):
|
|
|
155
191
|
print("Tip: add `followups = true` to ~/.config/sessionpulse/config.toml to do this automatically.")
|
|
156
192
|
|
|
157
193
|
|
|
194
|
+
def cmd_dedupe(cfg, args):
|
|
195
|
+
if not followups.claude_bin(cfg):
|
|
196
|
+
sys.exit("error: the `claude` command was not found; set claude_command in config.toml")
|
|
197
|
+
items = todos.load(cfg)
|
|
198
|
+
counts = Counter(i.get("project") or "" for i in items if todos.is_open(i))
|
|
199
|
+
projects = [args.project] if args.project is not None else [p for p, n in counts.items() if n > 1]
|
|
200
|
+
text = {i["id"]: i["text"] for i in items}
|
|
201
|
+
total = 0
|
|
202
|
+
for project in projects:
|
|
203
|
+
try:
|
|
204
|
+
groups = followups.find_duplicates(cfg, project)
|
|
205
|
+
except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as exc:
|
|
206
|
+
print(f"{project or '(no project)'}: skipped, {exc}", flush=True)
|
|
207
|
+
continue
|
|
208
|
+
if not groups:
|
|
209
|
+
continue
|
|
210
|
+
print(f"\n{project or '(no project)'}", flush=True)
|
|
211
|
+
for g in groups:
|
|
212
|
+
print(" same:")
|
|
213
|
+
for x in g:
|
|
214
|
+
print(f" {x} {text.get(x, '')}")
|
|
215
|
+
if args.apply:
|
|
216
|
+
n = todos.merge_duplicates(cfg, groups)
|
|
217
|
+
else:
|
|
218
|
+
n = sum(len(g) - 1 for g in groups)
|
|
219
|
+
total += n
|
|
220
|
+
if args.apply:
|
|
221
|
+
print(f"\nArchived {total} duplicate(s); each keeps `duplicate_of` pointing at the one kept.")
|
|
222
|
+
else:
|
|
223
|
+
print(f"\n{total} duplicate(s) found. Run again with --apply to archive them, keeping the newest.")
|
|
224
|
+
|
|
225
|
+
|
|
158
226
|
def main(argv=None):
|
|
159
227
|
p = argparse.ArgumentParser(prog="sessionpulse", description=__doc__)
|
|
160
228
|
p.add_argument("--version", action="version", version=f"sessionpulse {__version__}")
|
|
161
229
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
162
|
-
sub.add_parser("install", help="add Claude Code hooks and login autostart")
|
|
163
|
-
|
|
230
|
+
i = sub.add_parser("install", help="add Claude Code hooks and login autostart")
|
|
231
|
+
i.add_argument("--no-autostart", action="store_true", help="only add the hooks")
|
|
232
|
+
i.add_argument("--daemon", action="store_true",
|
|
233
|
+
help="autostart the background daemon (for the GNOME extension) instead of the panel icon")
|
|
164
234
|
sub.add_parser("uninstall", help="remove the hooks and autostart (keeps your data)")
|
|
165
235
|
sub.add_parser("tray", help="panel icon + dashboard server")
|
|
166
236
|
sub.add_parser("serve", help="dashboard server only")
|
|
237
|
+
sub.add_parser("daemon", help="dashboard server + status.json for the GNOME extension, no icon")
|
|
167
238
|
sub.add_parser("open", help="open the dashboard in your browser")
|
|
168
239
|
sub.add_parser("doctor", help="check the setup and data formats")
|
|
169
240
|
sub.add_parser("snapshot", help="print the dashboard data as JSON")
|
|
@@ -177,6 +248,9 @@ def main(argv=None):
|
|
|
177
248
|
f.add_argument("--session", help="only this session id (or its first characters)")
|
|
178
249
|
f.add_argument("--now", action="store_true", help="include sessions that are still active")
|
|
179
250
|
f.add_argument("--again", action="store_true", help="re-read sessions already read")
|
|
251
|
+
d = sub.add_parser("dedupe", help="find open to-dos that are the same work reworded, with Claude")
|
|
252
|
+
d.add_argument("-p", "--project", help="only this project (default: every project)")
|
|
253
|
+
d.add_argument("--apply", action="store_true", help="archive the copies (default: only list them)")
|
|
180
254
|
sub.add_parser("hook", help=argparse.SUPPRESS)
|
|
181
255
|
args = p.parse_args(argv)
|
|
182
256
|
cfg = config.load()
|
|
@@ -30,6 +30,7 @@ DIGEST_CHARS = 60000 # how much of a transcript goes to the model
|
|
|
30
30
|
USER_CHARS = 1200 # per prompt
|
|
31
31
|
CLAUDE_CHARS = 2500 # per reply
|
|
32
32
|
MAX_ITEMS = 12
|
|
33
|
+
MAX_OPEN = 150 # open to-dos shown to the model per project
|
|
33
34
|
TIMEOUT = 180
|
|
34
35
|
|
|
35
36
|
SYSTEM = """You read a transcript of a Claude Code session between a user and Claude and list the \
|
|
@@ -51,8 +52,9 @@ English, naming the concrete thing ("Ask the vendor for the September invoice br
|
|
|
51
52
|
Put the reason or context in `detail` (max 200 characters). Never copy passwords, keys or tokens into items.
|
|
52
53
|
|
|
53
54
|
You also get the user's currently open to-dos for this project, with ids. Put an id in `resolved` only \
|
|
54
|
-
if this transcript clearly shows that item was completed. Do not repeat an open to-do as a new item,
|
|
55
|
-
do not re-add anything listed
|
|
55
|
+
if this transcript clearly shows that item was completed. Do not repeat an open to-do as a new item, even \
|
|
56
|
+
in different words: if an open to-do already covers the work, leave it out. Do not re-add anything listed \
|
|
57
|
+
under "dismissed"."""
|
|
56
58
|
|
|
57
59
|
SCHEMA = {
|
|
58
60
|
"type": "object",
|
|
@@ -111,15 +113,15 @@ def claude_bin(cfg):
|
|
|
111
113
|
return None
|
|
112
114
|
|
|
113
115
|
|
|
114
|
-
def ask_claude(cfg, prompt):
|
|
116
|
+
def ask_claude(cfg, prompt, system=SYSTEM, schema=SCHEMA):
|
|
115
117
|
exe = claude_bin(cfg)
|
|
116
118
|
if not exe:
|
|
117
119
|
raise RuntimeError("the `claude` command was not found; set claude_command in config.toml")
|
|
118
120
|
work = cfg.data / "followups-cwd"
|
|
119
121
|
work.mkdir(parents=True, exist_ok=True)
|
|
120
122
|
cmd = [exe, "-p", "--model", cfg.followups_model, "--no-session-persistence", "--tools", "",
|
|
121
|
-
"--settings", json.dumps({"disableAllHooks": True}), "--system-prompt",
|
|
122
|
-
"--output-format", "json", "--json-schema", json.dumps(
|
|
123
|
+
"--settings", json.dumps({"disableAllHooks": True}), "--system-prompt", system,
|
|
124
|
+
"--output-format", "json", "--json-schema", json.dumps(schema)]
|
|
123
125
|
env = {**os.environ, "SESSIONPULSE_CHILD": "1"}
|
|
124
126
|
r = subprocess.run(cmd, input=prompt, capture_output=True, text=True, cwd=work, env=env, timeout=TIMEOUT)
|
|
125
127
|
try:
|
|
@@ -156,7 +158,10 @@ def process(cfg, sess, path):
|
|
|
156
158
|
"""Extract follow-ups from one session and merge them into the to-do list."""
|
|
157
159
|
items = todos.load(cfg)
|
|
158
160
|
project = sess.get("project") or ""
|
|
159
|
-
|
|
161
|
+
# newest first: the model must see recent items, or it adds them again reworded
|
|
162
|
+
open_ = sorted((i for i in items if not i.get("done_at") and i.get("status") != "archived"
|
|
163
|
+
and i.get("project") == project),
|
|
164
|
+
key=lambda i: i.get("created") or "", reverse=True)[:MAX_OPEN]
|
|
160
165
|
dismissed = [d for d in todos.dismissed(cfg) if d.get("project") == project][-40:]
|
|
161
166
|
prompt = digest(path, project)
|
|
162
167
|
prompt += "\n\n## Open to-dos for this project\n" + (
|
|
@@ -167,6 +172,39 @@ def process(cfg, sess, path):
|
|
|
167
172
|
return todos.merge_followups(cfg, sess, out.get("items") or [], out.get("resolved") or [])
|
|
168
173
|
|
|
169
174
|
|
|
175
|
+
# ---------- one-off cleanup ----------
|
|
176
|
+
DEDUPE_SYSTEM = """You get one project's open to-do list, one item per line with its id. Group the items \
|
|
177
|
+
that are the same piece of work written in different words. Reply only with the JSON the schema asks for.
|
|
178
|
+
|
|
179
|
+
Two items are duplicates only if doing one would finish the other. Items about different devices, sites, \
|
|
180
|
+
people, dates or steps are NOT duplicates, even if worded alike ("Disable Telnet on the routers" and \
|
|
181
|
+
"Disable WAN management on the routers" are separate). A broader item does not swallow a narrower one \
|
|
182
|
+
unless it names that same work. Leave out every item that has no duplicate. When unsure, leave it out."""
|
|
183
|
+
|
|
184
|
+
DEDUPE_SCHEMA = {
|
|
185
|
+
"type": "object",
|
|
186
|
+
"properties": {"groups": {"type": "array", "items": {
|
|
187
|
+
"type": "array", "minItems": 2, "items": {"type": "string"}}}},
|
|
188
|
+
"required": ["groups"],
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def find_duplicates(cfg, project):
|
|
193
|
+
"""Ask the model which open to-dos in one project are reworded copies; returns groups of ids."""
|
|
194
|
+
open_ = [i for i in todos.load(cfg) if todos.is_open(i) and (i.get("project") or "") == (project or "")]
|
|
195
|
+
if len(open_) < 2:
|
|
196
|
+
return []
|
|
197
|
+
ids = {i["id"] for i in open_}
|
|
198
|
+
out = ask_claude(cfg, "\n".join(f"[{i['id']}] {i['text']}" for i in open_),
|
|
199
|
+
system=DEDUPE_SYSTEM, schema=DEDUPE_SCHEMA)
|
|
200
|
+
groups = []
|
|
201
|
+
for g in out.get("groups") or []:
|
|
202
|
+
g = [x for x in dict.fromkeys(map(str, g)) if x in ids]
|
|
203
|
+
if len(g) > 1:
|
|
204
|
+
groups.append(g)
|
|
205
|
+
return groups
|
|
206
|
+
|
|
207
|
+
|
|
170
208
|
def candidates(cfg, snap, days=None, idle_minutes=None, force=False):
|
|
171
209
|
"""Sessions worth (re)reading: recent, quiet for a while, and changed since last read."""
|
|
172
210
|
days = cfg.followups_days if days is None else days
|
|
@@ -126,7 +126,8 @@ def uninstall_hooks(cfg: Config):
|
|
|
126
126
|
return changed
|
|
127
127
|
|
|
128
128
|
|
|
129
|
-
def enable_autostart():
|
|
129
|
+
def enable_autostart(mode="tray"):
|
|
130
|
+
"""Start `sessionpulse tray` (panel icon) or `sessionpulse daemon` (no icon) at login."""
|
|
130
131
|
icon = Path(__file__).with_name("icons") / "sessionpulse-idle.svg"
|
|
131
132
|
AUTOSTART.parent.mkdir(parents=True, exist_ok=True)
|
|
132
133
|
AUTOSTART.write_text(
|
|
@@ -134,7 +135,7 @@ def enable_autostart():
|
|
|
134
135
|
"Type=Application\n"
|
|
135
136
|
"Name=Session Pulse\n"
|
|
136
137
|
"Comment=Panel icon and dashboard for Claude Code sessions\n"
|
|
137
|
-
f'Exec="{sys.executable}" -m sessionpulse
|
|
138
|
+
f'Exec="{sys.executable}" -m sessionpulse {mode}\n'
|
|
138
139
|
f"Icon={icon}\n"
|
|
139
140
|
"Terminal=false\n"
|
|
140
141
|
"X-GNOME-Autostart-enabled=true\n"
|
|
@@ -180,6 +180,7 @@ input[type=search] { flex: 1; min-width: 200px; }
|
|
|
180
180
|
.k-decision { --c: var(--crit); } .k-external { --c: var(--busy); } .k-waiting { --c: var(--series-7); }
|
|
181
181
|
.k-checklist { --c: var(--accent); } .k-task { --c: var(--series-5); } .k-mine, .k-todo { --c: var(--other); }
|
|
182
182
|
.k-doing { --c: var(--accent); } .k-done { --c: var(--good); }
|
|
183
|
+
.k-hold { --c: var(--series-4); } .k-archived { --c: var(--other); }
|
|
183
184
|
.kindtag { color: color-mix(in srgb, var(--c) 78%, var(--text-primary));
|
|
184
185
|
background: color-mix(in srgb, var(--c) 16%, transparent); }
|
|
185
186
|
.kindtag.dot { background: var(--c); }
|
|
@@ -232,6 +233,7 @@ a:hover { text-decoration: underline; }
|
|
|
232
233
|
.card2.dragging { opacity: .4; }
|
|
233
234
|
.card2 { border-left: 3px solid var(--c); }
|
|
234
235
|
.card2.done { border-left-color: var(--good); }
|
|
236
|
+
.card2.parked .t { color: var(--text-secondary); }
|
|
235
237
|
.doing-pill { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: #fff;
|
|
236
238
|
background: var(--accent); border-radius: 5px; padding: 1px 6px; }
|
|
237
239
|
.card2.done .t { color: var(--text-secondary); }
|
|
@@ -651,6 +653,8 @@ $("#history").addEventListener("click", ev => {
|
|
|
651
653
|
const dayLabel = iso => { const d = dkey(new Date(iso)), t = new Date(); const today = dkey(t);
|
|
652
654
|
t.setDate(t.getDate()-1); return d === today ? "Today" : d === dkey(t) ? "Yesterday" : dayTitle(d); };
|
|
653
655
|
const KIND = {external: "Outside work", checklist: "Checklist", decision: "Decide", waiting: "Waiting on", task: "Later"};
|
|
656
|
+
const PARKED = ["hold", "archived"];
|
|
657
|
+
const isOpen = i => !i.done_at && !PARKED.includes(i.status);
|
|
654
658
|
const todoRow = (i, done, grouped) => {
|
|
655
659
|
const born = i.session_at || i.created;
|
|
656
660
|
const from = i.source === "session" && i.session
|
|
@@ -666,7 +670,7 @@ const todoRow = (i, done, grouped) => {
|
|
|
666
670
|
|
|
667
671
|
function renderTodos() {
|
|
668
672
|
const items = DATA.todos || [], r = rangeStart();
|
|
669
|
-
const open = items.filter(i =>
|
|
673
|
+
const open = items.filter(i => isOpen(i) && (!TODOF || (TODOF === "session") === (i.source === "session")))
|
|
670
674
|
.sort((a, b) => (a.source === "session") - (b.source === "session") || a.created.localeCompare(b.created));
|
|
671
675
|
const F = DATA.followups || {};
|
|
672
676
|
$("#fu-status").innerHTML = !F.enabled
|
|
@@ -676,7 +680,8 @@ function renderTodos() {
|
|
|
676
680
|
: `Follow-ups are picked up from sessions after 20 quiet minutes${F.last_run ? ` · last checked ${ago(Date.parse(F.last_run))}` : ""}.`;
|
|
677
681
|
const done = items.filter(i => i.done_at && (!r || dkey(new Date(i.done_at)) >= r))
|
|
678
682
|
.sort((a, b) => b.done_at.localeCompare(a.done_at));
|
|
679
|
-
|
|
683
|
+
const held = items.filter(i => !i.done_at && i.status === "hold").length, arch = items.filter(i => !i.done_at && i.status === "archived").length;
|
|
684
|
+
$("#todo-count").textContent = [open.length && `${open.length} open`, held && `${held} on hold`, arch && `${arch} archived`].filter(Boolean).join(" · ");
|
|
680
685
|
// Group by project, newest activity first. Groups with something from the last 3 days start open.
|
|
681
686
|
const G = {};
|
|
682
687
|
for (const i of open) (G[i.project || ""] ||= []).push(i);
|
|
@@ -772,27 +777,31 @@ function renderLearn() {
|
|
|
772
777
|
const COLS = {
|
|
773
778
|
kind: [["decision", "Decide", "choices waiting on you"], ["external", "Outside work", "send, call, sign, visit"],
|
|
774
779
|
["waiting", "Waiting on", "someone owes you"], ["checklist", "Checklist", "audit and verify"],
|
|
775
|
-
["task", "Later", "for a future session"], ["mine", "Added by me", ""], ["
|
|
776
|
-
|
|
780
|
+
["task", "Later", "for a future session"], ["mine", "Added by me", ""], ["hold", "On hold", "paused for now"],
|
|
781
|
+
["done", "Done", ""], ["archived", "Archived", "dropped, kept for reference"]],
|
|
782
|
+
status: [["todo", "To do", ""], ["doing", "Doing", "what you're on now"], ["hold", "On hold", "paused for now"],
|
|
783
|
+
["done", "Done", ""], ["archived", "Archived", "dropped, kept for reference"]],
|
|
777
784
|
};
|
|
778
785
|
let BY = "kind", DRAG = null;
|
|
779
|
-
const colOf = i => i.done_at ? "done" :
|
|
786
|
+
const colOf = i => i.done_at ? "done" : PARKED.includes(i.status) ? i.status
|
|
787
|
+
: BY === "status" ? (i.doing ? "doing" : "todo") : (i.kind || "mine");
|
|
780
788
|
function moveOp(i, col) {
|
|
781
789
|
if (col === "done") return {op: "set", id: i.id, done: true};
|
|
782
|
-
|
|
790
|
+
if (PARKED.includes(col)) return {op: "set", id: i.id, done: false, status: col};
|
|
791
|
+
const op = {op: "set", id: i.id, done: false, status: null};
|
|
783
792
|
if (BY === "status") op.doing = col === "doing";
|
|
784
793
|
else op.kind = col === "mine" ? null : col;
|
|
785
794
|
return op;
|
|
786
795
|
}
|
|
787
796
|
const card = i => {
|
|
788
|
-
const born = i.session_at || i.created, cols = COLS[BY], here = colOf(i);
|
|
789
|
-
return `<div class="card2 k-${esc(i.kind || "mine")}${i.doing && !
|
|
790
|
-
<div class="t">${BY === "status" && i.kind ? `<span class="kindtag k-${esc(i.kind)}">${esc(KIND[i.kind])}</span>` : ""}${esc(i.text)}</div>
|
|
797
|
+
const born = i.session_at || i.created, cols = COLS[BY], here = colOf(i), parked = PARKED.includes(here);
|
|
798
|
+
return `<div class="card2 k-${esc(i.kind || "mine")}${i.doing && here !== "done" && !parked ? " doing" : ""}${i.done_at ? " done" : ""}${parked ? " parked" : ""}" draggable="true" data-id="${esc(i.id)}">
|
|
799
|
+
<div class="t">${(BY === "status" || parked) && i.kind ? `<span class="kindtag k-${esc(i.kind)}">${esc(KIND[i.kind])}</span>` : ""}${esc(i.text)}</div>
|
|
791
800
|
${i.detail ? `<div class="d" title="Click to expand">${esc(i.detail)}</div>` : ""}
|
|
792
|
-
<div class="meta">${i.doing && !i.done_at && BY === "kind" ? `<span class="doing-pill">Doing</span>` : ""}${i.project ? `<span class="tag" title="${esc(i.project)}">${esc(i.project.split("/").pop())}</span>` : ""}
|
|
801
|
+
<div class="meta">${i.doing && !i.done_at && !parked && BY === "kind" ? `<span class="doing-pill">Doing</span>` : ""}${i.project ? `<span class="tag" title="${esc(i.project)}">${esc(i.project.split("/").pop())}</span>` : ""}
|
|
793
802
|
<span title="${i.done_at ? "Done " + esc(fmtD(i.done_at)) : (i.session_at ? "Discussed " : "Added ") + esc(fmtD(born))}">${i.done_at ? "done " + ago(Date.parse(i.done_at)) : ago(Date.parse(born))}</span>
|
|
794
803
|
${i.closed_by && i.done_at ? `<span title="A later session showed this was done">· auto</span>` : ""}
|
|
795
|
-
<span class="acts">${i.session && !i.done_at ? `<button class="copy" data-copy="cd ${esc(i.cwd || "")} && claude --resume ${esc(i.session)}" title="Copy the command that reopens the session this came from">Resume</button>` : ""}
|
|
804
|
+
<span class="acts">${i.session && !i.done_at && !parked ? `<button class="copy" data-copy="cd ${esc(i.cwd || "")} && claude --resume ${esc(i.session)}" title="Copy the command that reopens the session this came from">Resume</button>` : ""}
|
|
796
805
|
<select data-move="${esc(i.id)}" aria-label="Move to"><option value="">Move…</option>${cols.filter(c => c[0] !== here).map(c => `<option value="${c[0]}">${c[1]}</option>`).join("")}</select>
|
|
797
806
|
<button class="x" data-del="${esc(i.id)}" title="${i.source === "session" ? "Dismiss (won't be picked up again)" : "Delete"}" aria-label="Delete">×</button></span>
|
|
798
807
|
</div></div>`;
|
|
@@ -802,9 +811,9 @@ function renderBoard() {
|
|
|
802
811
|
if (!DATA || DRAG || $("#boardview").hidden) return;
|
|
803
812
|
if (document.activeElement?.matches?.("#board select")) return; // don't yank a menu that's open
|
|
804
813
|
const items = DATA.todos || [], r = rangeStart(), proj = $("#board-proj").value, q = $("#board-q").value.trim().toLowerCase();
|
|
805
|
-
const projects = [...new Set(items.filter(i => !i.done_at).map(i => i.project).filter(Boolean))].sort();
|
|
814
|
+
const projects = [...new Set(items.filter(i => !i.done_at).map(i => i.project).filter(Boolean))].sort(); // parked ones too, so you can find them
|
|
806
815
|
const sel = $("#board-proj");
|
|
807
|
-
const opts = `<option value="">All projects</option>` + projects.map(p => `<option value="${esc(p)}"${p === proj ? " selected" : ""}>${esc(p)} (${items.filter(i =>
|
|
816
|
+
const opts = `<option value="">All projects</option>` + projects.map(p => `<option value="${esc(p)}"${p === proj ? " selected" : ""}>${esc(p)} (${items.filter(i => isOpen(i) && i.project === p).length})</option>`).join("");
|
|
808
817
|
if (sel.dataset.sig !== opts) { sel.innerHTML = opts; sel.dataset.sig = opts; sel.value = projects.includes(proj) ? proj : ""; }
|
|
809
818
|
const shown = items.filter(i => (!proj || i.project === proj)
|
|
810
819
|
&& (!q || [i.text, i.detail, i.project].some(v => (v||"").toLowerCase().includes(q)))
|
|
@@ -813,12 +822,12 @@ function renderBoard() {
|
|
|
813
822
|
const when = i => i.done_at || i.session_at || i.created;
|
|
814
823
|
$("#board").innerHTML = COLS[BY].map(([k, label, hint]) => {
|
|
815
824
|
const list = (by[k] || []).sort((a, b) => when(b).localeCompare(when(a)));
|
|
816
|
-
const cap = k === "done" ? 150 : 400;
|
|
825
|
+
const cap = k === "done" || k === "archived" ? 150 : 400;
|
|
817
826
|
return `<section class="col k-${k}" data-col="${k}"><div class="col-head"><span class="kindtag dot k-${k}"></span>${label}<span class="n">${list.length}</span>${hint ? `<span class="hint">${hint}</span>` : ""}</div>
|
|
818
|
-
<div class="col-body">${list.slice(0, cap).map(card).join("") || `<div class="col-empty">${k === "done" ? "Drop a card here when it's done" : "Nothing here"}</div>`}
|
|
827
|
+
<div class="col-body">${list.slice(0, cap).map(card).join("") || `<div class="col-empty">${k === "done" ? "Drop a card here when it's done" : k === "hold" ? "Drop a card here to pause it" : k === "archived" ? "Drop a card here to shelve it" : "Nothing here"}</div>`}
|
|
819
828
|
${list.length > cap ? `<div class="col-empty">+${list.length - cap} more; narrow it with the project filter</div>` : ""}</div></section>`;
|
|
820
829
|
}).join("");
|
|
821
|
-
const open = items.filter(
|
|
830
|
+
const open = items.filter(isOpen).length;
|
|
822
831
|
$("#board-note").textContent = `${open} open · Done shows ${RANGE ? (RANGE === 1 ? "today" : `the last ${RANGE} days`) : "everything"} (change it top right) · drag cards between columns`;
|
|
823
832
|
}
|
|
824
833
|
|
|
@@ -859,7 +868,7 @@ B.addEventListener("click", ev => {
|
|
|
859
868
|
// ---------- wiring ----------
|
|
860
869
|
function render() {
|
|
861
870
|
if (!DATA) return;
|
|
862
|
-
const open = (DATA.todos || []).filter(
|
|
871
|
+
const open = (DATA.todos || []).filter(isOpen).length;
|
|
863
872
|
$("#board-badge").textContent = open ? open : "";
|
|
864
873
|
if (!$("#boardview").hidden) return renderBoard();
|
|
865
874
|
const sessions = DATA.sessions.filter(inRange);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""`status.json`: a small, token-free summary for panel widgets such as the GNOME extension.
|
|
2
|
+
|
|
3
|
+
`sessionpulse daemon` rewrites it every few seconds (atomically, mode 0600) and
|
|
4
|
+
removes it on a clean exit. A reader should treat it as stale when `updated` is
|
|
5
|
+
older than about three intervals: the daemon was killed or the machine slept.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import __version__, config, reader, todos, tray
|
|
13
|
+
|
|
14
|
+
SCHEMA = 1
|
|
15
|
+
INTERVAL = 5 # seconds between writes
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def path(cfg: config.Config) -> Path:
|
|
19
|
+
return cfg.data / "status.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build(cfg: config.Config, snap, today=None):
|
|
23
|
+
v = tray.summary(snap, today=today)
|
|
24
|
+
now = int(time.time() * 1000)
|
|
25
|
+
return {
|
|
26
|
+
"schema": SCHEMA,
|
|
27
|
+
"version": __version__,
|
|
28
|
+
"updated": now,
|
|
29
|
+
"interval": INTERVAL,
|
|
30
|
+
"pid": os.getpid(),
|
|
31
|
+
"port": cfg.port,
|
|
32
|
+
"state": v["icon"], # busy | idle | none
|
|
33
|
+
"open": len(v["live"]),
|
|
34
|
+
"working": v["busy"],
|
|
35
|
+
"sessions": [{
|
|
36
|
+
"project": s.get("project") or "?",
|
|
37
|
+
"state": "busy" if s.get("state") == "busy" else "idle",
|
|
38
|
+
"started": (s.get("live") or {}).get("startedAt") or now,
|
|
39
|
+
} for s in v["live"]],
|
|
40
|
+
"today": {
|
|
41
|
+
"sessions": v["today_sessions"],
|
|
42
|
+
"active_hours": round(v["today_hours"], 2),
|
|
43
|
+
"output_tokens": int(v["today_output"]),
|
|
44
|
+
},
|
|
45
|
+
"todos_open": sum(1 for t in snap.get("todos") or [] if todos.is_open(t)),
|
|
46
|
+
"warnings": len(v["warnings"]),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write(cfg: config.Config, data):
|
|
51
|
+
"""Atomic replace, readable only by you (project names are private enough)."""
|
|
52
|
+
target = path(cfg)
|
|
53
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
tmp = target.with_name(f".status.{os.getpid()}.tmp")
|
|
55
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
56
|
+
with os.fdopen(fd, "w") as f:
|
|
57
|
+
json.dump(data, f, separators=(",", ":"))
|
|
58
|
+
os.replace(tmp, target)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def write_now(cfg: config.Config, today=None):
|
|
62
|
+
with reader.lock:
|
|
63
|
+
snap = reader.snapshot(cfg)
|
|
64
|
+
data = build(cfg, snap, today=today)
|
|
65
|
+
write(cfg, data)
|
|
66
|
+
return data
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run(cfg: config.Config, stop, on_error=None):
|
|
70
|
+
"""Write status.json every INTERVAL seconds until `stop` (a threading.Event) is set, then remove it."""
|
|
71
|
+
try:
|
|
72
|
+
while not stop.is_set():
|
|
73
|
+
try:
|
|
74
|
+
write_now(cfg)
|
|
75
|
+
except Exception as exc: # noqa: BLE001 - keep the daemon alive
|
|
76
|
+
if on_error:
|
|
77
|
+
on_error(exc)
|
|
78
|
+
stop.wait(INTERVAL)
|
|
79
|
+
finally:
|
|
80
|
+
remove(cfg)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def remove(cfg: config.Config):
|
|
84
|
+
try:
|
|
85
|
+
path(cfg).unlink()
|
|
86
|
+
except FileNotFoundError:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def age(cfg: config.Config):
|
|
91
|
+
"""Seconds since the daemon last wrote status.json, or None if there is none."""
|
|
92
|
+
try:
|
|
93
|
+
data = json.loads(path(cfg).read_text())
|
|
94
|
+
return max(0.0, time.time() - data["updated"] / 1000)
|
|
95
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
96
|
+
return None
|
|
@@ -13,6 +13,7 @@ from datetime import datetime
|
|
|
13
13
|
|
|
14
14
|
MAX_TEXT = 500
|
|
15
15
|
KINDS = ("external", "checklist", "decision", "waiting", "task")
|
|
16
|
+
STATUSES = ("hold", "archived") # parked items: not done, but not on the active list either
|
|
16
17
|
lock = threading.Lock()
|
|
17
18
|
|
|
18
19
|
|
|
@@ -46,6 +47,11 @@ def _save(cfg, items):
|
|
|
46
47
|
_write(_path(cfg), items)
|
|
47
48
|
|
|
48
49
|
|
|
50
|
+
def is_open(item):
|
|
51
|
+
"""Still to do: not done, on hold or archived."""
|
|
52
|
+
return not item.get("done_at") and item.get("status") not in STATUSES
|
|
53
|
+
|
|
54
|
+
|
|
49
55
|
def _dismissed_path(cfg):
|
|
50
56
|
return cfg.data / "todos-dismissed.json"
|
|
51
57
|
|
|
@@ -63,19 +69,58 @@ def _norm(text):
|
|
|
63
69
|
return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()
|
|
64
70
|
|
|
65
71
|
|
|
72
|
+
SIMILAR = 0.6 # word overlap at which two to-dos count as the same one reworded
|
|
73
|
+
RECENT_DAYS = 30
|
|
74
|
+
_STOP = frozenset("a an the and or of to in on for with from by at as is be it its this that into via "
|
|
75
|
+
"per their your our my all any each then than not no do get set up out off whether".split())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _words(text):
|
|
79
|
+
"""The content words of a to-do, with a plain plural 's' dropped."""
|
|
80
|
+
out = set()
|
|
81
|
+
for w in re.findall(r"[a-z0-9]+", text.lower()):
|
|
82
|
+
if w in _STOP:
|
|
83
|
+
continue
|
|
84
|
+
if len(w) > 3 and w.endswith("s") and not w.endswith("ss"):
|
|
85
|
+
w = w[:-1]
|
|
86
|
+
out.add(w)
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def similar(a, b):
|
|
91
|
+
"""True if two to-do texts say the same thing in different words."""
|
|
92
|
+
wa, wb = _words(a), _words(b)
|
|
93
|
+
return bool(wa and wb) and len(wa & wb) / len(wa | wb) >= SIMILAR
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _recent(stamp, days=RECENT_DAYS):
|
|
97
|
+
try:
|
|
98
|
+
return (datetime.now().astimezone() - datetime.fromisoformat(stamp)).days < days
|
|
99
|
+
except (TypeError, ValueError):
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
66
103
|
def merge_followups(cfg, sess, found, resolved):
|
|
67
104
|
"""Add follow-ups found in a session and close the ones it resolved.
|
|
68
105
|
|
|
69
|
-
Returns (added, closed). Skips anything already on the list or dismissed
|
|
106
|
+
Returns (added, closed). Skips anything already on the list or dismissed,
|
|
107
|
+
including the same to-do reworded (see similar()) within the project."""
|
|
70
108
|
added = closed = 0
|
|
109
|
+
project = sess.get("project")
|
|
71
110
|
with lock:
|
|
72
111
|
items = load(cfg)
|
|
73
|
-
|
|
112
|
+
gone = dismissed(cfg)
|
|
113
|
+
seen = {_norm(i["text"]) for i in items} | {_norm(d.get("text", "")) for d in gone}
|
|
114
|
+
# reworded copies: compare with this project's live items, recently done ones and dismissals
|
|
115
|
+
near = [i["text"] for i in items if i.get("project") == project
|
|
116
|
+
and (not i.get("done_at") or _recent(i["done_at"]))]
|
|
117
|
+
near += [d.get("text", "") for d in gone if d.get("project") == project]
|
|
74
118
|
for f in found:
|
|
75
119
|
text = _clean(f.get("text") if isinstance(f, dict) else "", 200)
|
|
76
|
-
if not text or _norm(text) in seen:
|
|
120
|
+
if not text or _norm(text) in seen or any(similar(text, t) for t in near):
|
|
77
121
|
continue
|
|
78
122
|
seen.add(_norm(text))
|
|
123
|
+
near.append(text)
|
|
79
124
|
kind = f.get("kind") if f.get("kind") in KINDS else "task"
|
|
80
125
|
items.append({"id": secrets.token_hex(4), "text": text, "project": sess.get("project"),
|
|
81
126
|
"created": _now(), "done_at": None, "source": "session", "kind": kind,
|
|
@@ -84,7 +129,7 @@ def merge_followups(cfg, sess, found, resolved):
|
|
|
84
129
|
"session_at": sess.get("end")})
|
|
85
130
|
added += 1
|
|
86
131
|
for i in items:
|
|
87
|
-
if i["id"] in resolved and not i.get("done_at") and i.get("project") ==
|
|
132
|
+
if i["id"] in resolved and not i.get("done_at") and i.get("project") == project:
|
|
88
133
|
i["done_at"] = _now()
|
|
89
134
|
i["closed_by"] = sess.get("id")
|
|
90
135
|
closed += 1
|
|
@@ -93,6 +138,31 @@ def merge_followups(cfg, sess, found, resolved):
|
|
|
93
138
|
return added, closed
|
|
94
139
|
|
|
95
140
|
|
|
141
|
+
def merge_duplicates(cfg, groups):
|
|
142
|
+
"""Archive all but one open item of each group of duplicate ids; returns how many.
|
|
143
|
+
|
|
144
|
+
The kept item is the one in progress, else the newest. Archived copies record
|
|
145
|
+
`duplicate_of`, so they stay on the board and can be moved back."""
|
|
146
|
+
merged = 0
|
|
147
|
+
with lock:
|
|
148
|
+
items = load(cfg)
|
|
149
|
+
by_id = {i["id"]: i for i in items}
|
|
150
|
+
for group in groups:
|
|
151
|
+
live = [by_id[g] for g in dict.fromkeys(group) if g in by_id and is_open(by_id[g])]
|
|
152
|
+
if len(live) < 2:
|
|
153
|
+
continue
|
|
154
|
+
keep = max(live, key=lambda i: (bool(i.get("doing")), i.get("created") or ""))
|
|
155
|
+
for i in live:
|
|
156
|
+
if i is not keep:
|
|
157
|
+
i["status"] = "archived"
|
|
158
|
+
i["doing"] = False
|
|
159
|
+
i["duplicate_of"] = keep["id"]
|
|
160
|
+
merged += 1
|
|
161
|
+
if merged:
|
|
162
|
+
_save(cfg, items)
|
|
163
|
+
return merged
|
|
164
|
+
|
|
165
|
+
|
|
96
166
|
def _clean(text, limit=MAX_TEXT):
|
|
97
167
|
return " ".join(str(text or "").split())[:limit]
|
|
98
168
|
|
|
@@ -101,7 +171,7 @@ def apply(cfg, op):
|
|
|
101
171
|
"""Run one change and return the new list.
|
|
102
172
|
|
|
103
173
|
op is a dict: {"op": "add", "text", "project"?} | {"op": "done"|"undo"|"delete", "id"}
|
|
104
|
-
| {"op": "delete", "ids": [...]} | {"op": "set", "id", "kind"?, "doing"?, "done"?}
|
|
174
|
+
| {"op": "delete", "ids": [...]} | {"op": "set", "id", "kind"?, "doing"?, "done"?, "status"?}
|
|
105
175
|
| {"op": "edit", "id", "text"}. Raises ValueError on anything else."""
|
|
106
176
|
if not isinstance(op, dict):
|
|
107
177
|
raise ValueError("expected an object")
|
|
@@ -134,15 +204,26 @@ def apply(cfg, op):
|
|
|
134
204
|
hit["kind"] = op["kind"] or None
|
|
135
205
|
if "doing" in op:
|
|
136
206
|
hit["doing"] = bool(op["doing"])
|
|
207
|
+
if "status" in op:
|
|
208
|
+
if op["status"] not in (*STATUSES, None, ""):
|
|
209
|
+
raise ValueError("unknown status")
|
|
210
|
+
if op["status"]:
|
|
211
|
+
hit["status"] = op["status"]
|
|
212
|
+
hit["doing"] = False
|
|
213
|
+
else:
|
|
214
|
+
hit.pop("status", None)
|
|
215
|
+
hit.pop("duplicate_of", None)
|
|
137
216
|
if "done" in op:
|
|
138
217
|
hit["done_at"] = (hit.get("done_at") or _now()) if op["done"] else None
|
|
139
218
|
if not op["done"]:
|
|
140
219
|
hit.pop("closed_by", None)
|
|
141
220
|
if hit.get("done_at"):
|
|
142
221
|
hit["doing"] = False
|
|
222
|
+
hit.pop("status", None)
|
|
143
223
|
elif kind == "done":
|
|
144
224
|
hit["done_at"] = hit.get("done_at") or _now()
|
|
145
225
|
hit["doing"] = False
|
|
226
|
+
hit.pop("status", None)
|
|
146
227
|
elif kind == "undo":
|
|
147
228
|
hit["done_at"] = None
|
|
148
229
|
hit.pop("closed_by", None)
|
|
@@ -13,7 +13,7 @@ import webbrowser
|
|
|
13
13
|
from datetime import datetime
|
|
14
14
|
from importlib import resources
|
|
15
15
|
|
|
16
|
-
from . import config, followups, reader, server
|
|
16
|
+
from . import config, followups, reader, server, todos
|
|
17
17
|
|
|
18
18
|
SLOTS = 8 # fixed rows for open sessions: replacing the whole menu would close it while open
|
|
19
19
|
LIVE = ("busy", "idle", "running")
|
|
@@ -140,7 +140,7 @@ def run(cfg: config.Config):
|
|
|
140
140
|
f"{fmt_n(v['today_output'])} output tokens")
|
|
141
141
|
warn.set_label(f"⚠ {len(v['warnings'])} data warning(s): see dashboard")
|
|
142
142
|
warn.set_visible(bool(v["warnings"]))
|
|
143
|
-
n = sum(1 for t in snap.get("todos") or [] if
|
|
143
|
+
n = sum(1 for t in snap.get("todos") or [] if todos.is_open(t))
|
|
144
144
|
board.set_label(f"Open to-do board ({n} open)" if n else "Open to-do board")
|
|
145
145
|
return False
|
|
146
146
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sessionpulse
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: A quiet instrument panel for Claude Code: live sessions, usage, and a to-do board that fills itself from your sessions. Local only.
|
|
5
5
|
Author: Vijay Shukla
|
|
6
6
|
License-Expression: MIT
|
|
@@ -154,6 +154,27 @@ sessionpulse followups # read the last 30 days now instead of waiting
|
|
|
154
154
|
|
|
155
155
|
Check everything with `sessionpulse doctor`. Remove it with `sessionpulse uninstall` (your data stays) and `pipx uninstall sessionpulse`.
|
|
156
156
|
|
|
157
|
+
## GNOME extension
|
|
158
|
+
|
|
159
|
+
A native top-bar indicator for GNOME Shell 45 to 48, as an alternative to the AppIndicator panel icon. It shows the same thing: the number of open sessions, orange while one is working, plus a menu with each session, today's totals, and links to the dashboard and the to-do board.
|
|
160
|
+
|
|
161
|
+
The extension only reads `~/.local/share/sessionpulse/status.json` ([format](https://github.com/vijayshuklaHL/sessionpulse/blob/main/docs/status-json.md)), which `sessionpulse daemon` writes, so the app has to be installed too:
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
pipx install sessionpulse
|
|
165
|
+
sessionpulse install --daemon # hooks, plus the daemon (no icon) at login
|
|
166
|
+
sessionpulse daemon & # start it now
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Then install the extension. It isn't on extensions.gnome.org yet, so install it from a clone of this repo:
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
gnome-extension/pack.sh
|
|
173
|
+
gnome-extensions install --force gnome-extension/dist/sessionpulse@vijayshuklaHL.github.io.shell-extension.zip
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Log out and back in (on X11 you can press Alt+F2 and type `r` instead), then run `gnome-extensions enable sessionpulse@vijayshuklaHL.github.io`. If you used the panel icon before, stop it: run `sessionpulse install --daemon` again so login starts the daemon instead of the tray.
|
|
177
|
+
|
|
157
178
|
## Commands
|
|
158
179
|
|
|
159
180
|
| Command | What it does |
|
|
@@ -162,10 +183,12 @@ Check everything with `sessionpulse doctor`. Remove it with `sessionpulse uninst
|
|
|
162
183
|
| `sessionpulse uninstall` | Removes exactly those hooks and the autostart entry. Your data stays. |
|
|
163
184
|
| `sessionpulse tray` | Panel icon and dashboard server |
|
|
164
185
|
| `sessionpulse serve` | Dashboard server only |
|
|
186
|
+
| `sessionpulse daemon` | Dashboard server plus `status.json` for panel widgets such as the GNOME extension, no icon (`install --daemon` starts it at login) |
|
|
165
187
|
| `sessionpulse open` | Opens the dashboard in your browser, signed in (add `#board` to the address for the board) |
|
|
166
188
|
| `sessionpulse doctor` | Checks the setup and warns if Claude Code's file formats look different |
|
|
167
189
|
| `sessionpulse todo [add TEXT -p PROJECT \| done ID \| undo ID \| delete ID \| list-done]` | Your to-do list from the terminal |
|
|
168
190
|
| `sessionpulse followups [--days N] [--session ID] [--now] [--again]` | Read recent sessions now and add their open follow-ups |
|
|
191
|
+
| `sessionpulse dedupe [-p PROJECT] [--apply]` | Finds open to-dos that are the same work reworded; `--apply` archives the copies |
|
|
169
192
|
| `sessionpulse snapshot` | Prints the dashboard data as JSON |
|
|
170
193
|
|
|
171
194
|
## Settings
|
|
@@ -14,7 +14,7 @@ from pathlib import Path
|
|
|
14
14
|
|
|
15
15
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
16
16
|
|
|
17
|
-
from sessionpulse import cli, config, followups, hook, install, learn, reader, server, todos, tray # noqa: E402
|
|
17
|
+
from sessionpulse import cli, config, followups, hook, install, learn, reader, server, status, todos, tray # noqa: E402
|
|
18
18
|
|
|
19
19
|
SID = "11111111-2222-3333-4444-555555555555"
|
|
20
20
|
|
|
@@ -313,6 +313,31 @@ class TodoTests(Fixture):
|
|
|
313
313
|
self.assertEqual(sorted(d["text"] for d in todos.dismissed(self.cfg)), ["a", "b"])
|
|
314
314
|
self.assertEqual(todos.merge_followups(self.cfg, {"id": "s2", "project": "p"}, [{"text": "a"}], []), (0, 0))
|
|
315
315
|
|
|
316
|
+
def test_reworded_followups_skipped(self):
|
|
317
|
+
merge = todos.merge_followups
|
|
318
|
+
merge(self.cfg, {"id": "s", "project": "p"},
|
|
319
|
+
[{"text": "Put Grafana alert rules on no data and execution error"}], [])
|
|
320
|
+
self.assertEqual(merge(self.cfg, {"id": "s2", "project": "p"},
|
|
321
|
+
[{"text": "Check that Grafana alert rules fire on no data and on execution error"},
|
|
322
|
+
{"text": "Disable Telnet on the Credo routers"},
|
|
323
|
+
{"text": "Disable WAN management on the Credo routers"}], []), (2, 0))
|
|
324
|
+
# the same words in another project are a different to-do
|
|
325
|
+
self.assertEqual(merge(self.cfg, {"id": "s3", "project": "q"},
|
|
326
|
+
[{"text": "Check Grafana alert rules on no data and execution error"}], []), (1, 0))
|
|
327
|
+
|
|
328
|
+
def test_merge_duplicates(self):
|
|
329
|
+
a = todos.apply(self.cfg, {"op": "add", "text": "old wording"})[-1]["id"]
|
|
330
|
+
b = todos.apply(self.cfg, {"op": "add", "text": "new wording"})[-1]["id"]
|
|
331
|
+
c = todos.apply(self.cfg, {"op": "add", "text": "in progress"})[-1]["id"]
|
|
332
|
+
todos.apply(self.cfg, {"op": "set", "id": c, "doing": True})
|
|
333
|
+
self.assertEqual(todos.merge_duplicates(self.cfg, [[a, b, c], [a, "missing"]]), 2)
|
|
334
|
+
by = {i["id"]: i for i in todos.load(self.cfg)}
|
|
335
|
+
self.assertTrue(todos.is_open(by[c])) # the one in progress is kept
|
|
336
|
+
self.assertEqual([(by[x]["status"], by[x]["duplicate_of"]) for x in (a, b)], [("archived", c)] * 2)
|
|
337
|
+
it = next(i for i in todos.apply(self.cfg, {"op": "set", "id": a, "status": None}) if i["id"] == a)
|
|
338
|
+
self.assertNotIn("duplicate_of", it)
|
|
339
|
+
self.assertEqual(todos.merge_duplicates(self.cfg, [[b, c]]), 0) # b is already archived
|
|
340
|
+
|
|
316
341
|
def test_board_moves(self):
|
|
317
342
|
i = todos.apply(self.cfg, {"op": "add", "text": "x"})[0]["id"]
|
|
318
343
|
it = todos.apply(self.cfg, {"op": "set", "id": i, "kind": "decision", "doing": True})[0]
|
|
@@ -325,6 +350,23 @@ class TodoTests(Fixture):
|
|
|
325
350
|
with self.assertRaises(ValueError):
|
|
326
351
|
todos.apply(self.cfg, {"op": "set", "id": i, "kind": "nope"})
|
|
327
352
|
|
|
353
|
+
def test_hold_and_archive(self):
|
|
354
|
+
i = todos.apply(self.cfg, {"op": "add", "text": "x"})[0]["id"]
|
|
355
|
+
it = todos.apply(self.cfg, {"op": "set", "id": i, "doing": True})[0]
|
|
356
|
+
it = todos.apply(self.cfg, {"op": "set", "id": i, "done": False, "status": "hold"})[0]
|
|
357
|
+
self.assertEqual((it["status"], it["doing"], it["done_at"]), ("hold", False, None))
|
|
358
|
+
self.assertFalse(todos.is_open(it))
|
|
359
|
+
it = todos.apply(self.cfg, {"op": "set", "id": i, "status": "archived"})[0]
|
|
360
|
+
self.assertEqual(it["status"], "archived")
|
|
361
|
+
it = todos.apply(self.cfg, {"op": "set", "id": i, "done": False, "status": None})[0]
|
|
362
|
+
self.assertNotIn("status", it)
|
|
363
|
+
self.assertTrue(todos.is_open(it))
|
|
364
|
+
todos.apply(self.cfg, {"op": "set", "id": i, "status": "hold"})
|
|
365
|
+
it = todos.apply(self.cfg, {"op": "done", "id": i})[0]
|
|
366
|
+
self.assertNotIn("status", it) # finishing a held item un-parks it
|
|
367
|
+
with self.assertRaises(ValueError):
|
|
368
|
+
todos.apply(self.cfg, {"op": "set", "id": i, "status": "someday"})
|
|
369
|
+
|
|
328
370
|
def test_bad_ops_rejected(self):
|
|
329
371
|
for op in ({"op": "add", "text": " "}, {"op": "done", "id": "missing"}, {"op": "x"}, []):
|
|
330
372
|
with self.assertRaises(ValueError):
|
|
@@ -346,6 +388,72 @@ class TodoTests(Fixture):
|
|
|
346
388
|
self.assertIn("call Sam [ops] done", out.getvalue())
|
|
347
389
|
|
|
348
390
|
|
|
391
|
+
class StatusTests(Fixture):
|
|
392
|
+
def live_session(self):
|
|
393
|
+
t = self.t0
|
|
394
|
+
self.write([user(t, "hi"), assistant(t, "m1", 42)])
|
|
395
|
+
(self.cfg.claude / "sessions" / "1.json").write_text(json.dumps(
|
|
396
|
+
{"pid": os.getpid(), "sessionId": SID, "status": "busy", "startedAt": 1234}))
|
|
397
|
+
return t.date().isoformat()
|
|
398
|
+
|
|
399
|
+
def test_status_file(self):
|
|
400
|
+
day = self.live_session()
|
|
401
|
+
todos.apply(self.cfg, {"op": "add", "text": "ship it"})
|
|
402
|
+
data = status.write_now(self.cfg, today=day)
|
|
403
|
+
on_disk = json.loads(status.path(self.cfg).read_text())
|
|
404
|
+
self.assertEqual(on_disk, data)
|
|
405
|
+
self.assertEqual((data["schema"], data["state"], data["open"], data["working"]), (1, "busy", 1, 1))
|
|
406
|
+
self.assertEqual(data["sessions"][0]["state"], "busy")
|
|
407
|
+
self.assertEqual(data["sessions"][0]["started"], 1234)
|
|
408
|
+
self.assertEqual(data["today"]["output_tokens"], 42)
|
|
409
|
+
self.assertEqual(data["todos_open"], 1)
|
|
410
|
+
self.assertEqual(status.path(self.cfg).stat().st_mode & 0o777, 0o600)
|
|
411
|
+
self.assertNotIn(config.token(self.cfg), status.path(self.cfg).read_text())
|
|
412
|
+
self.assertLess(status.age(self.cfg), 5)
|
|
413
|
+
self.assertEqual(list(self.cfg.data.glob(".status.*")), []) # no temp files left
|
|
414
|
+
|
|
415
|
+
def test_no_sessions_and_removal(self):
|
|
416
|
+
data = status.write_now(self.cfg)
|
|
417
|
+
self.assertEqual((data["state"], data["open"], data["sessions"]), ("none", 0, []))
|
|
418
|
+
status.remove(self.cfg)
|
|
419
|
+
status.remove(self.cfg) # twice is fine
|
|
420
|
+
self.assertIsNone(status.age(self.cfg))
|
|
421
|
+
|
|
422
|
+
def test_daemon_loop_writes_then_cleans_up(self):
|
|
423
|
+
self.live_session()
|
|
424
|
+
stop, seen = threading.Event(), []
|
|
425
|
+
|
|
426
|
+
def watch():
|
|
427
|
+
for _ in range(200):
|
|
428
|
+
if status.path(self.cfg).exists():
|
|
429
|
+
seen.append(json.loads(status.path(self.cfg).read_text())["open"])
|
|
430
|
+
break
|
|
431
|
+
stop.wait(0.01)
|
|
432
|
+
stop.set()
|
|
433
|
+
|
|
434
|
+
threading.Thread(target=watch).start()
|
|
435
|
+
with unittest.mock.patch.object(status, "INTERVAL", 0.02):
|
|
436
|
+
status.run(self.cfg, stop)
|
|
437
|
+
self.assertEqual(seen, [1])
|
|
438
|
+
self.assertFalse(status.path(self.cfg).exists())
|
|
439
|
+
|
|
440
|
+
def test_daemon_command_runs_without_icon(self):
|
|
441
|
+
with unittest.mock.patch.object(config, "load", return_value=self.cfg), \
|
|
442
|
+
unittest.mock.patch.object(cli.signal, "signal"), \
|
|
443
|
+
unittest.mock.patch.object(cli.followups, "start_worker"), \
|
|
444
|
+
unittest.mock.patch.object(cli.status, "run") as run, \
|
|
445
|
+
unittest.mock.patch("sys.stdout", io.StringIO()) as out:
|
|
446
|
+
cli.main(["daemon"])
|
|
447
|
+
run.assert_called_once()
|
|
448
|
+
self.assertIn("status.json", out.getvalue())
|
|
449
|
+
|
|
450
|
+
def test_install_daemon_autostart(self):
|
|
451
|
+
target = Path(self.tmp.name) / "autostart" / "sessionpulse.desktop"
|
|
452
|
+
with unittest.mock.patch.object(install, "AUTOSTART", target):
|
|
453
|
+
install.enable_autostart("daemon")
|
|
454
|
+
self.assertIn("-m sessionpulse daemon", target.read_text())
|
|
455
|
+
|
|
456
|
+
|
|
349
457
|
class HeadlessTests(Fixture):
|
|
350
458
|
def run_cli(self, argv, env):
|
|
351
459
|
out = io.StringIO()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|