pocketshell 0.3.31__tar.gz → 0.3.33__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.
- {pocketshell-0.3.31 → pocketshell-0.3.33}/PKG-INFO +1 -1
- {pocketshell-0.3.31 → pocketshell-0.3.33}/pyproject.toml +1 -1
- pocketshell-0.3.33/scheduler/README.md +74 -0
- pocketshell-0.3.33/scheduler/pocketshell-usage-capture.service +14 -0
- pocketshell-0.3.33/scheduler/pocketshell-usage-capture.timer +15 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/cli.py +2 -0
- pocketshell-0.3.33/src/pocketshell/prune_attachments.py +372 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/usage.py +153 -0
- pocketshell-0.3.33/src/pocketshell/usage_capture.py +294 -0
- pocketshell-0.3.33/src/pocketshell/usage_reset.py +352 -0
- pocketshell-0.3.33/tests/test_prune_attachments.py +296 -0
- pocketshell-0.3.33/tests/test_usage_capture.py +199 -0
- pocketshell-0.3.33/tests/test_usage_reset.py +265 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/uv.lock +2 -2
- {pocketshell-0.3.31 → pocketshell-0.3.33}/.gitignore +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/README.md +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/__init__.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/agent_log.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/daemon.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/env.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/github.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/jobs.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/src/pocketshell/sessions.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/__init__.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_agent_log.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_cli.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_daemon.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_env.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_github.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_hooks.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_jobs.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_logs.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_qr_share.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_repos.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_sessions.py +0 -0
- {pocketshell-0.3.31 → pocketshell-0.3.33}/tests/test_usage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pocketshell
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.33
|
|
4
4
|
Summary: Unified server-side Python utility for the PocketShell Android client.
|
|
5
5
|
Project-URL: Homepage, https://github.com/alexeygrigorev/pocketshell
|
|
6
6
|
Project-URL: Issues, https://github.com/alexeygrigorev/pocketshell/issues
|
|
@@ -8,7 +8,7 @@ name = "pocketshell"
|
|
|
8
8
|
# scripts/check-pypi-version.sh enforces this; .github/workflows/build.yml
|
|
9
9
|
# runs that check before publishing to PyPI. See
|
|
10
10
|
# tools/pocketshell/README.md ("Release flow") for the bump procedure.
|
|
11
|
-
version = "0.3.
|
|
11
|
+
version = "0.3.33"
|
|
12
12
|
description = "Unified server-side Python utility for the PocketShell Android client."
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Scheduled usage capture
|
|
2
|
+
|
|
3
|
+
PocketShell's usage screen is stale-while-revalidate (issue #689): the host
|
|
4
|
+
captures provider usage on a schedule and the app renders the last captured
|
|
5
|
+
reading **instantly** before its own live foreground refresh swaps in fresh
|
|
6
|
+
data.
|
|
7
|
+
|
|
8
|
+
This directory holds the host-side scheduler units that drive the capture.
|
|
9
|
+
Server-side scheduling is fine — the foreground-only rule (D21) applies to the
|
|
10
|
+
Android app, not the host CLI.
|
|
11
|
+
|
|
12
|
+
## What `--capture` does
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pocketshell usage --capture
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Fetches usage live (via the daemon when one is running, else a one-shot
|
|
19
|
+
subprocess), then writes two artifacts under
|
|
20
|
+
`${XDG_STATE_HOME:-~/.local/state}/pocketshell/usage/`:
|
|
21
|
+
|
|
22
|
+
- `usage-latest.json` — the cached latest reading
|
|
23
|
+
(`{"captured_at": "...Z", "records": [ … ]}`), mode `0600`.
|
|
24
|
+
- `usage-history.jsonl` — an append-only history log, one capture per line,
|
|
25
|
+
trimmed to the most recent 2000 lines (~83 days at hourly capture; the file
|
|
26
|
+
stays well under ~1 MB). No external logrotate dependency.
|
|
27
|
+
|
|
28
|
+
A failed live fetch is **not** cached, so a transient provider hiccup never
|
|
29
|
+
pins a bad reading.
|
|
30
|
+
|
|
31
|
+
The app reads the cache with:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pocketshell usage --cached
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
which prints `usage-latest.json` instantly (exit 3 with a friendly note when no
|
|
38
|
+
capture has run yet, so the app falls back to a pure live fetch).
|
|
39
|
+
|
|
40
|
+
## Install (systemd user timer — recommended)
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
mkdir -p ~/.config/systemd/user
|
|
44
|
+
cp pocketshell-usage-capture.service ~/.config/systemd/user/
|
|
45
|
+
cp pocketshell-usage-capture.timer ~/.config/systemd/user/
|
|
46
|
+
systemctl --user daemon-reload
|
|
47
|
+
systemctl --user enable --now pocketshell-usage-capture.timer
|
|
48
|
+
|
|
49
|
+
# Keep the timer running after you log out (so the laptop/server captures
|
|
50
|
+
# on schedule without an active session):
|
|
51
|
+
loginctl enable-linger "$USER"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Check it:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
systemctl --user list-timers pocketshell-usage-capture.timer
|
|
58
|
+
journalctl --user -u pocketshell-usage-capture.service --no-pager
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The `.service` uses `%h/.local/bin/pocketshell` (the `uv tool install` /
|
|
62
|
+
`pipx install` location). Adjust `ExecStart` if `pocketshell` lives elsewhere
|
|
63
|
+
on your `PATH`.
|
|
64
|
+
|
|
65
|
+
## Install (cron alternative)
|
|
66
|
+
|
|
67
|
+
For hosts without systemd, an hourly cron line works the same way:
|
|
68
|
+
|
|
69
|
+
```cron
|
|
70
|
+
# m h dom mon dow command
|
|
71
|
+
17 * * * * $HOME/.local/bin/pocketshell usage --capture >/dev/null 2>&1
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
(The minute offset spreads the call off the top of the hour.)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=PocketShell usage capture (writes the cached latest reading + history log)
|
|
3
|
+
Documentation=https://github.com/alexeygrigorev/pocketshell/blob/main/docs/usage-panel.md
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
Type=oneshot
|
|
7
|
+
# Capture usage live, write usage-latest.json + append usage-history.jsonl
|
|
8
|
+
# under $XDG_STATE_HOME/pocketshell/usage/. %h expands to the user's home,
|
|
9
|
+
# so this works with `systemctl --user` (the recommended install).
|
|
10
|
+
ExecStart=%h/.local/bin/pocketshell usage --capture
|
|
11
|
+
# Keep capture failures (a transient provider hiccup) non-fatal for the
|
|
12
|
+
# timer: a single missed capture just means the app shows a slightly older
|
|
13
|
+
# cached reading until the next run.
|
|
14
|
+
SuccessExitStatus=0 1
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Run PocketShell usage capture hourly
|
|
3
|
+
Documentation=https://github.com/alexeygrigorev/pocketshell/blob/main/docs/usage-panel.md
|
|
4
|
+
|
|
5
|
+
[Timer]
|
|
6
|
+
# Hourly capture. RandomizedDelaySec spreads the provider API calls so a
|
|
7
|
+
# fleet of hosts does not hit the endpoints all at once on the hour.
|
|
8
|
+
OnCalendar=hourly
|
|
9
|
+
RandomizedDelaySec=300
|
|
10
|
+
# Catch up after the machine was asleep/off so a laptop that missed the
|
|
11
|
+
# hourly tick still refreshes the cache on next boot.
|
|
12
|
+
Persistent=true
|
|
13
|
+
|
|
14
|
+
[Install]
|
|
15
|
+
WantedBy=timers.target
|
|
@@ -28,6 +28,7 @@ from pocketshell.github import github_group
|
|
|
28
28
|
from pocketshell.hooks import hooks_group
|
|
29
29
|
from pocketshell.jobs import jobs_group
|
|
30
30
|
from pocketshell.logs import logs_group
|
|
31
|
+
from pocketshell.prune_attachments import prune_attachments_command
|
|
31
32
|
from pocketshell.qr_share import qr_share_command
|
|
32
33
|
from pocketshell.repos import repos_group
|
|
33
34
|
from pocketshell.sessions import sessions_group
|
|
@@ -58,6 +59,7 @@ cli.add_command(github_group, name="github")
|
|
|
58
59
|
cli.add_command(env_group, name="env")
|
|
59
60
|
cli.add_command(hooks_group, name="hooks")
|
|
60
61
|
cli.add_command(logs_group, name="logs")
|
|
62
|
+
cli.add_command(prune_attachments_command, name="prune-attachments")
|
|
61
63
|
cli.add_command(qr_share_command, name="qr-share")
|
|
62
64
|
|
|
63
65
|
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""`pocketshell prune-attachments` — server-side attachment retention backstop.
|
|
2
|
+
|
|
3
|
+
The PocketShell Android composer uploads each prompt attachment to the
|
|
4
|
+
remote host under ``~/.pocketshell/attachments/<host-scope>/`` (see
|
|
5
|
+
``PromptAttachmentStager.REMOTE_DIRECTORY`` on the client). Nothing on the
|
|
6
|
+
host ever removes those files, so the directory grows unbounded on every
|
|
7
|
+
host the user attaches to (issue #547).
|
|
8
|
+
|
|
9
|
+
The client already prunes the *active* scope dir on a fresh upload
|
|
10
|
+
(``RemoteAttachmentPruner`` — option 1 in #547). This command is the
|
|
11
|
+
**server-side backstop** (option 2): it runs ON the host, over the whole
|
|
12
|
+
``~/.pocketshell/attachments/`` tree, so even hosts the user stopped
|
|
13
|
+
attaching to are eventually trimmed. It is meant to be invoked by the
|
|
14
|
+
normal ``pocketshell`` plumbing (e.g. on connect / during usage probes) or
|
|
15
|
+
from a maintainer's cron.
|
|
16
|
+
|
|
17
|
+
Safety bounds (deny-by-default — this deletes files):
|
|
18
|
+
|
|
19
|
+
- **Scoped to one directory.** Only files directly under
|
|
20
|
+
``~/.pocketshell/attachments/<scope>/`` (depth-2 from the root) are ever
|
|
21
|
+
considered. The root and the per-scope directories themselves are never
|
|
22
|
+
deleted; the user's own files outside the attachments tree are never
|
|
23
|
+
touched. The resolved root must stay inside ``$HOME`` or the command
|
|
24
|
+
refuses to run.
|
|
25
|
+
- **Regular files only.** Symlinks, directories, sockets, and anything
|
|
26
|
+
that is not a plain regular file is skipped — a symlink inside the
|
|
27
|
+
attachments dir can never be used to delete a file elsewhere.
|
|
28
|
+
- **Age bound (TTL).** A file is a deletion candidate only when its mtime
|
|
29
|
+
is strictly older than ``DEFAULT_TTL_DAYS`` (14 days).
|
|
30
|
+
- **Size cap.** After the TTL pass, if the *surviving* attachments still
|
|
31
|
+
exceed ``DEFAULT_MAX_TOTAL_BYTES`` (256 MiB), the oldest survivors are
|
|
32
|
+
deleted (oldest-first) until the tree is back under the cap. Files
|
|
33
|
+
younger than ``PROTECT_NEWEST_HOURS`` (24h) are never deleted by the
|
|
34
|
+
size cap so an active session's just-uploaded files are spared even
|
|
35
|
+
during a big backlog clear.
|
|
36
|
+
- **Dry-run default off, but ``--dry-run`` reports without deleting.**
|
|
37
|
+
|
|
38
|
+
The command is best-effort and never raises on a per-file delete error
|
|
39
|
+
(permissions, races) — it logs the failure into the result summary and
|
|
40
|
+
continues, so a single bad file can't wedge the whole prune.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import json
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import Optional
|
|
49
|
+
|
|
50
|
+
import click
|
|
51
|
+
|
|
52
|
+
# Retention tuning knobs. Kept as module constants so both the CLI and the
|
|
53
|
+
# unit suite reference one source of truth.
|
|
54
|
+
DEFAULT_TTL_DAYS: int = 14
|
|
55
|
+
DEFAULT_MAX_TOTAL_BYTES: int = 256 * 1024 * 1024 # 256 MiB
|
|
56
|
+
PROTECT_NEWEST_HOURS: int = 24
|
|
57
|
+
|
|
58
|
+
# The attachments root, relative to ``$HOME``. Mirrors the client's
|
|
59
|
+
# ``PromptAttachmentStager.REMOTE_DIRECTORY``.
|
|
60
|
+
ATTACHMENTS_RELATIVE_ROOT = Path(".pocketshell") / "attachments"
|
|
61
|
+
|
|
62
|
+
_SECONDS_PER_DAY = 24 * 60 * 60
|
|
63
|
+
_SECONDS_PER_HOUR = 60 * 60
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class DeletedFile:
|
|
68
|
+
"""One file the prune removed (or would remove in dry-run)."""
|
|
69
|
+
|
|
70
|
+
path: str
|
|
71
|
+
size: int
|
|
72
|
+
age_days: float
|
|
73
|
+
reason: str # "ttl" or "size-cap"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class PruneResult:
|
|
78
|
+
"""Structured summary of a prune pass (CLI emits this as JSON)."""
|
|
79
|
+
|
|
80
|
+
root: str
|
|
81
|
+
scanned_files: int = 0
|
|
82
|
+
scanned_bytes: int = 0
|
|
83
|
+
deleted: list[DeletedFile] = field(default_factory=list)
|
|
84
|
+
errors: list[str] = field(default_factory=list)
|
|
85
|
+
dry_run: bool = False
|
|
86
|
+
skipped_root_missing: bool = False
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def deleted_count(self) -> int:
|
|
90
|
+
return len(self.deleted)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def deleted_bytes(self) -> int:
|
|
94
|
+
return sum(d.size for d in self.deleted)
|
|
95
|
+
|
|
96
|
+
def to_dict(self) -> dict:
|
|
97
|
+
return {
|
|
98
|
+
"root": self.root,
|
|
99
|
+
"dry_run": self.dry_run,
|
|
100
|
+
"skipped_root_missing": self.skipped_root_missing,
|
|
101
|
+
"scanned_files": self.scanned_files,
|
|
102
|
+
"scanned_bytes": self.scanned_bytes,
|
|
103
|
+
"deleted_count": self.deleted_count,
|
|
104
|
+
"deleted_bytes": self.deleted_bytes,
|
|
105
|
+
"deleted": [
|
|
106
|
+
{
|
|
107
|
+
"path": d.path,
|
|
108
|
+
"size": d.size,
|
|
109
|
+
"age_days": round(d.age_days, 3),
|
|
110
|
+
"reason": d.reason,
|
|
111
|
+
}
|
|
112
|
+
for d in self.deleted
|
|
113
|
+
],
|
|
114
|
+
"errors": self.errors,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class _Candidate:
|
|
120
|
+
path: Path
|
|
121
|
+
size: int
|
|
122
|
+
mtime: float
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def resolve_attachments_root(home: Optional[Path] = None) -> Path:
|
|
126
|
+
"""Resolve ``~/.pocketshell/attachments`` from ``$HOME``.
|
|
127
|
+
|
|
128
|
+
Pulled out so the unit suite can point it at a tmp dir. Uses the
|
|
129
|
+
``home`` argument when given, otherwise ``Path.home()``.
|
|
130
|
+
"""
|
|
131
|
+
base = home if home is not None else Path.home()
|
|
132
|
+
return (base / ATTACHMENTS_RELATIVE_ROOT).resolve()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _iter_attachment_files(root: Path) -> list[_Candidate]:
|
|
136
|
+
"""Collect regular files exactly two levels under ``root``.
|
|
137
|
+
|
|
138
|
+
Layout is ``root/<scope>/<file>``. We deliberately do NOT recurse
|
|
139
|
+
deeper and we never follow symlinks: a candidate must be a real
|
|
140
|
+
regular file (``Path.is_file()`` follows symlinks, so we additionally
|
|
141
|
+
reject symlinks with ``is_symlink()``).
|
|
142
|
+
"""
|
|
143
|
+
candidates: list[_Candidate] = []
|
|
144
|
+
for scope_dir in _safe_iterdir(root):
|
|
145
|
+
# Only descend into real directories that are direct children of
|
|
146
|
+
# the root — never symlinked dirs (which could point outside the
|
|
147
|
+
# attachments tree).
|
|
148
|
+
if scope_dir.is_symlink() or not scope_dir.is_dir():
|
|
149
|
+
continue
|
|
150
|
+
for entry in _safe_iterdir(scope_dir):
|
|
151
|
+
if entry.is_symlink():
|
|
152
|
+
continue
|
|
153
|
+
if not entry.is_file():
|
|
154
|
+
continue
|
|
155
|
+
try:
|
|
156
|
+
stat = entry.stat()
|
|
157
|
+
except OSError:
|
|
158
|
+
continue
|
|
159
|
+
candidates.append(
|
|
160
|
+
_Candidate(path=entry, size=stat.st_size, mtime=stat.st_mtime)
|
|
161
|
+
)
|
|
162
|
+
return candidates
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _safe_iterdir(directory: Path) -> list[Path]:
|
|
166
|
+
try:
|
|
167
|
+
return sorted(directory.iterdir())
|
|
168
|
+
except OSError:
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def prune_attachments(
|
|
173
|
+
root: Path,
|
|
174
|
+
*,
|
|
175
|
+
now: float,
|
|
176
|
+
ttl_days: int = DEFAULT_TTL_DAYS,
|
|
177
|
+
max_total_bytes: int = DEFAULT_MAX_TOTAL_BYTES,
|
|
178
|
+
protect_newest_hours: int = PROTECT_NEWEST_HOURS,
|
|
179
|
+
dry_run: bool = False,
|
|
180
|
+
) -> PruneResult:
|
|
181
|
+
"""Prune attachments under ``root`` by TTL then by size cap.
|
|
182
|
+
|
|
183
|
+
``root`` MUST be the resolved ``~/.pocketshell/attachments`` directory;
|
|
184
|
+
the caller is responsible for the ``$HOME`` containment check (the CLI
|
|
185
|
+
does it). ``now`` is the current epoch-seconds (injected so the unit
|
|
186
|
+
suite is deterministic).
|
|
187
|
+
"""
|
|
188
|
+
result = PruneResult(root=str(root), dry_run=dry_run)
|
|
189
|
+
|
|
190
|
+
if not root.exists() or not root.is_dir():
|
|
191
|
+
result.skipped_root_missing = True
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
candidates = _iter_attachment_files(root)
|
|
195
|
+
result.scanned_files = len(candidates)
|
|
196
|
+
result.scanned_bytes = sum(c.size for c in candidates)
|
|
197
|
+
|
|
198
|
+
ttl_seconds = ttl_days * _SECONDS_PER_DAY
|
|
199
|
+
protect_seconds = protect_newest_hours * _SECONDS_PER_HOUR
|
|
200
|
+
|
|
201
|
+
survivors: list[_Candidate] = []
|
|
202
|
+
|
|
203
|
+
# Pass 1 — TTL. A file strictly older than the TTL is deleted.
|
|
204
|
+
for c in candidates:
|
|
205
|
+
age = now - c.mtime
|
|
206
|
+
if age > ttl_seconds:
|
|
207
|
+
_delete(result, c, now=now, reason="ttl", dry_run=dry_run)
|
|
208
|
+
else:
|
|
209
|
+
survivors.append(c)
|
|
210
|
+
|
|
211
|
+
# Pass 2 — size cap. If survivors still exceed the cap, delete the
|
|
212
|
+
# oldest (lowest mtime first) until back under, but never delete a file
|
|
213
|
+
# younger than the protect window.
|
|
214
|
+
surviving_bytes = sum(c.size for c in survivors)
|
|
215
|
+
if surviving_bytes > max_total_bytes:
|
|
216
|
+
# Oldest first.
|
|
217
|
+
for c in sorted(survivors, key=lambda x: x.mtime):
|
|
218
|
+
if surviving_bytes <= max_total_bytes:
|
|
219
|
+
break
|
|
220
|
+
age = now - c.mtime
|
|
221
|
+
if age < protect_seconds:
|
|
222
|
+
continue
|
|
223
|
+
_delete(result, c, now=now, reason="size-cap", dry_run=dry_run)
|
|
224
|
+
surviving_bytes -= c.size
|
|
225
|
+
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _delete(
|
|
230
|
+
result: PruneResult,
|
|
231
|
+
candidate: _Candidate,
|
|
232
|
+
*,
|
|
233
|
+
now: float,
|
|
234
|
+
reason: str,
|
|
235
|
+
dry_run: bool,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Record (and, unless dry-run, perform) one deletion. Best-effort."""
|
|
238
|
+
age_days = (now - candidate.mtime) / _SECONDS_PER_DAY
|
|
239
|
+
if not dry_run:
|
|
240
|
+
try:
|
|
241
|
+
candidate.path.unlink()
|
|
242
|
+
except OSError as exc:
|
|
243
|
+
result.errors.append(f"{candidate.path}: {exc}")
|
|
244
|
+
return
|
|
245
|
+
result.deleted.append(
|
|
246
|
+
DeletedFile(
|
|
247
|
+
path=str(candidate.path),
|
|
248
|
+
size=candidate.size,
|
|
249
|
+
age_days=age_days,
|
|
250
|
+
reason=reason,
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@click.command(
|
|
256
|
+
name="prune-attachments",
|
|
257
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
258
|
+
help=(
|
|
259
|
+
"Prune the ~/.pocketshell/attachments/ tree on this host.\n\n"
|
|
260
|
+
"Server-side retention backstop for prompt attachments uploaded by "
|
|
261
|
+
"the PocketShell composer (issue #547). Deletes regular files older "
|
|
262
|
+
"than the TTL, then trims the oldest survivors if the tree still "
|
|
263
|
+
"exceeds the size cap. Only touches files inside the attachments "
|
|
264
|
+
"directory; never the user's own files. Best-effort: per-file "
|
|
265
|
+
"errors are reported, not raised."
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
@click.option(
|
|
269
|
+
"--ttl-days",
|
|
270
|
+
type=click.IntRange(min=0),
|
|
271
|
+
default=DEFAULT_TTL_DAYS,
|
|
272
|
+
show_default=True,
|
|
273
|
+
help="Delete attachments strictly older than this many days.",
|
|
274
|
+
)
|
|
275
|
+
@click.option(
|
|
276
|
+
"--max-total-mib",
|
|
277
|
+
type=click.IntRange(min=0),
|
|
278
|
+
default=DEFAULT_MAX_TOTAL_BYTES // (1024 * 1024),
|
|
279
|
+
show_default=True,
|
|
280
|
+
help=(
|
|
281
|
+
"After the TTL pass, trim oldest survivors until the tree is under "
|
|
282
|
+
"this size (MiB). Files younger than the protect window are spared."
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
@click.option(
|
|
286
|
+
"--protect-newest-hours",
|
|
287
|
+
type=click.IntRange(min=0),
|
|
288
|
+
default=PROTECT_NEWEST_HOURS,
|
|
289
|
+
show_default=True,
|
|
290
|
+
help="Never let the size cap delete files younger than this.",
|
|
291
|
+
)
|
|
292
|
+
@click.option(
|
|
293
|
+
"--dry-run",
|
|
294
|
+
is_flag=True,
|
|
295
|
+
help="Report what would be deleted without deleting anything.",
|
|
296
|
+
)
|
|
297
|
+
@click.option(
|
|
298
|
+
"--json",
|
|
299
|
+
"as_json",
|
|
300
|
+
is_flag=True,
|
|
301
|
+
help="Emit the prune summary as JSON.",
|
|
302
|
+
)
|
|
303
|
+
def prune_attachments_command(
|
|
304
|
+
ttl_days: int,
|
|
305
|
+
max_total_mib: int,
|
|
306
|
+
protect_newest_hours: int,
|
|
307
|
+
dry_run: bool,
|
|
308
|
+
as_json: bool,
|
|
309
|
+
) -> None:
|
|
310
|
+
"""CLI entrypoint for ``pocketshell prune-attachments``."""
|
|
311
|
+
import time
|
|
312
|
+
|
|
313
|
+
home = Path.home().resolve()
|
|
314
|
+
root = resolve_attachments_root(home)
|
|
315
|
+
|
|
316
|
+
# Containment guard: the resolved root must stay inside $HOME. This is a
|
|
317
|
+
# belt-and-braces check against a tampered $HOME / ATTACHMENTS root.
|
|
318
|
+
if not _is_within(root, home):
|
|
319
|
+
raise click.ClickException(
|
|
320
|
+
f"refusing to prune {root}: not inside $HOME ({home})"
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
result = prune_attachments(
|
|
324
|
+
root,
|
|
325
|
+
now=time.time(),
|
|
326
|
+
ttl_days=ttl_days,
|
|
327
|
+
max_total_bytes=max_total_mib * 1024 * 1024,
|
|
328
|
+
protect_newest_hours=protect_newest_hours,
|
|
329
|
+
dry_run=dry_run,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if as_json:
|
|
333
|
+
click.echo(json.dumps(result.to_dict(), indent=2))
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
if result.skipped_root_missing:
|
|
337
|
+
click.echo(f"no attachments directory at {root}; nothing to prune.")
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
verb = "would delete" if dry_run else "deleted"
|
|
341
|
+
mib = result.deleted_bytes / (1024 * 1024)
|
|
342
|
+
click.echo(
|
|
343
|
+
f"scanned {result.scanned_files} files "
|
|
344
|
+
f"({result.scanned_bytes / (1024 * 1024):.1f} MiB); "
|
|
345
|
+
f"{verb} {result.deleted_count} ({mib:.1f} MiB)."
|
|
346
|
+
)
|
|
347
|
+
for d in result.deleted:
|
|
348
|
+
click.echo(f" {verb}: {d.path} ({d.reason}, {d.age_days:.1f}d)")
|
|
349
|
+
for err in result.errors:
|
|
350
|
+
click.echo(f" error: {err}", err=True)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _is_within(path: Path, parent: Path) -> bool:
|
|
354
|
+
"""True when ``path`` is ``parent`` or a descendant of it."""
|
|
355
|
+
try:
|
|
356
|
+
path.relative_to(parent)
|
|
357
|
+
return True
|
|
358
|
+
except ValueError:
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
__all__ = [
|
|
363
|
+
"DEFAULT_TTL_DAYS",
|
|
364
|
+
"DEFAULT_MAX_TOTAL_BYTES",
|
|
365
|
+
"PROTECT_NEWEST_HOURS",
|
|
366
|
+
"ATTACHMENTS_RELATIVE_ROOT",
|
|
367
|
+
"DeletedFile",
|
|
368
|
+
"PruneResult",
|
|
369
|
+
"resolve_attachments_root",
|
|
370
|
+
"prune_attachments",
|
|
371
|
+
"prune_attachments_command",
|
|
372
|
+
]
|
|
@@ -536,6 +536,37 @@ def _try_daemon_usage_fetch(
|
|
|
536
536
|
"No effect on the one-shot subprocess path, which always runs fresh."
|
|
537
537
|
),
|
|
538
538
|
)
|
|
539
|
+
@click.option(
|
|
540
|
+
"--capture",
|
|
541
|
+
"capture",
|
|
542
|
+
is_flag=True,
|
|
543
|
+
help=(
|
|
544
|
+
"Fetch usage live, write the cached latest reading + append to the "
|
|
545
|
+
"history log under $XDG_STATE_HOME/pocketshell/usage/, then exit. "
|
|
546
|
+
"Run this on a schedule (cron / systemd timer). Implies --json."
|
|
547
|
+
),
|
|
548
|
+
)
|
|
549
|
+
@click.option(
|
|
550
|
+
"--cached",
|
|
551
|
+
"cached",
|
|
552
|
+
is_flag=True,
|
|
553
|
+
help=(
|
|
554
|
+
"Emit the last captured reading instantly (no live fetch) as a JSON "
|
|
555
|
+
"document {captured_at, records} for the app's stale-while-revalidate "
|
|
556
|
+
"render. Exits non-zero if no capture exists yet. Implies --json."
|
|
557
|
+
),
|
|
558
|
+
)
|
|
559
|
+
@click.option(
|
|
560
|
+
"--reset-events",
|
|
561
|
+
"reset_events",
|
|
562
|
+
is_flag=True,
|
|
563
|
+
help=(
|
|
564
|
+
"Emit the recorded limit/session reset events (#690) as a JSON "
|
|
565
|
+
"document {reset_events: [...]} from the history log. The app reads "
|
|
566
|
+
"this to surface 'limits reset at <time>' on next open. Emits an "
|
|
567
|
+
"empty list when no resets have been detected yet. Implies --json."
|
|
568
|
+
),
|
|
569
|
+
)
|
|
539
570
|
@click.pass_context
|
|
540
571
|
def usage_command(
|
|
541
572
|
ctx: click.Context,
|
|
@@ -543,6 +574,9 @@ def usage_command(
|
|
|
543
574
|
json_output: bool = False,
|
|
544
575
|
no_daemon: bool = False,
|
|
545
576
|
no_cache: bool = False,
|
|
577
|
+
capture: bool = False,
|
|
578
|
+
cached: bool = False,
|
|
579
|
+
reset_events: bool = False,
|
|
546
580
|
) -> None:
|
|
547
581
|
"""Report quota / usage for coding-agent providers on this host.
|
|
548
582
|
|
|
@@ -552,7 +586,32 @@ def usage_command(
|
|
|
552
586
|
it falls through to ``quse [provider] [--json]`` as a one-shot
|
|
553
587
|
subprocess. JSON output is normalized into PocketShell's schema so
|
|
554
588
|
the app is not pinned to provider-specific `quse` quirks.
|
|
589
|
+
|
|
590
|
+
``--capture`` and ``--cached`` implement the stale-while-revalidate
|
|
591
|
+
cache (#689): a scheduled ``--capture`` writes the latest reading +
|
|
592
|
+
history log on the host, and the app reads ``--cached`` for an instant
|
|
593
|
+
populated render before its own live foreground refresh swaps in fresh
|
|
594
|
+
data.
|
|
555
595
|
"""
|
|
596
|
+
# `--cached` emits the last captured reading instantly; `--capture`
|
|
597
|
+
# fetches live and persists the cache + history. Both bypass the
|
|
598
|
+
# normal stdout-proxy path below. They imply JSON output.
|
|
599
|
+
if reset_events:
|
|
600
|
+
exit_code = _emit_reset_events()
|
|
601
|
+
if exit_code != 0:
|
|
602
|
+
ctx.exit(exit_code)
|
|
603
|
+
return
|
|
604
|
+
if cached:
|
|
605
|
+
exit_code = _emit_cached_usage()
|
|
606
|
+
if exit_code != 0:
|
|
607
|
+
ctx.exit(exit_code)
|
|
608
|
+
return
|
|
609
|
+
if capture:
|
|
610
|
+
exit_code = _capture_usage(provider, no_daemon=no_daemon)
|
|
611
|
+
if exit_code != 0:
|
|
612
|
+
ctx.exit(exit_code)
|
|
613
|
+
return
|
|
614
|
+
|
|
556
615
|
# JSON output is the format the daemon caches against (NDJSON
|
|
557
616
|
# straight from `quse --json`). Human-readable output is rare and
|
|
558
617
|
# not on the Android hot path, so it does not get the daemon
|
|
@@ -586,6 +645,100 @@ def usage_command(
|
|
|
586
645
|
ctx.exit(exit_code)
|
|
587
646
|
|
|
588
647
|
|
|
648
|
+
def _fetch_usage_ndjson(
|
|
649
|
+
provider: Optional[str],
|
|
650
|
+
*,
|
|
651
|
+
no_daemon: bool,
|
|
652
|
+
) -> tuple[Optional[str], str, int]:
|
|
653
|
+
"""Fetch live usage NDJSON for the cache capture.
|
|
654
|
+
|
|
655
|
+
Returns ``(stdout, stderr, returncode)`` where ``stdout`` is the
|
|
656
|
+
normalized NDJSON (or ``None`` when ``quse`` is missing). Mirrors the
|
|
657
|
+
command's own daemon-then-subprocess fall-through so a scheduled
|
|
658
|
+
``--capture`` benefits from the daemon cache when one is live.
|
|
659
|
+
"""
|
|
660
|
+
if not no_daemon:
|
|
661
|
+
envelope = _try_daemon_usage_fetch(provider, no_cache=False)
|
|
662
|
+
if envelope is not None:
|
|
663
|
+
stdout = normalize_usage_stdout(str(envelope.get("stdout") or ""))
|
|
664
|
+
stderr = str(envelope.get("stderr") or "")
|
|
665
|
+
return stdout, stderr, int(envelope.get("returncode", 0))
|
|
666
|
+
|
|
667
|
+
quse_path = _resolve_quse_binary()
|
|
668
|
+
if quse_path is None:
|
|
669
|
+
return (
|
|
670
|
+
None,
|
|
671
|
+
(
|
|
672
|
+
"pocketshell: `quse` is not installed on this host. "
|
|
673
|
+
"Install it via `uv tool install quse` or `pipx install quse` "
|
|
674
|
+
"and re-run.\n"
|
|
675
|
+
),
|
|
676
|
+
127,
|
|
677
|
+
)
|
|
678
|
+
args: list[str] = [quse_path]
|
|
679
|
+
if provider:
|
|
680
|
+
args.append(provider)
|
|
681
|
+
args.append("--json")
|
|
682
|
+
completed = subprocess.run(args, check=False, capture_output=True, text=True)
|
|
683
|
+
return normalize_usage_stdout(completed.stdout), completed.stderr, completed.returncode
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _capture_usage(provider: Optional[str], *, no_daemon: bool) -> int:
|
|
687
|
+
"""Fetch usage live and persist the cache + history log, then report.
|
|
688
|
+
|
|
689
|
+
Designed to run on a schedule. On a successful fetch it writes
|
|
690
|
+
``usage-latest.json`` and appends to ``usage-history.jsonl`` under
|
|
691
|
+
``$XDG_STATE_HOME/pocketshell/usage/`` and echoes the cache object so a
|
|
692
|
+
cron/systemd log shows what landed. A failed fetch is NOT cached so a
|
|
693
|
+
transient provider hiccup never pins a bad reading.
|
|
694
|
+
"""
|
|
695
|
+
from pocketshell import usage_capture as _capture
|
|
696
|
+
|
|
697
|
+
stdout, stderr, returncode = _fetch_usage_ndjson(provider, no_daemon=no_daemon)
|
|
698
|
+
if returncode != 0 or stdout is None:
|
|
699
|
+
if stderr:
|
|
700
|
+
sys.stderr.write(stderr)
|
|
701
|
+
return returncode if returncode != 0 else 1
|
|
702
|
+
|
|
703
|
+
cache_obj = _capture.write_capture(stdout)
|
|
704
|
+
sys.stdout.write(json.dumps(cache_obj, sort_keys=True) + "\n")
|
|
705
|
+
return 0
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _emit_cached_usage() -> int:
|
|
709
|
+
"""Emit the last captured reading as a JSON document, or exit non-zero.
|
|
710
|
+
|
|
711
|
+
Returns exit code 0 when a cache exists (its JSON document is written to
|
|
712
|
+
stdout), or 3 with a friendly stderr note when no capture has run yet so
|
|
713
|
+
the app can fall back to a pure live fetch.
|
|
714
|
+
"""
|
|
715
|
+
from pocketshell import usage_capture as _capture
|
|
716
|
+
|
|
717
|
+
document = _capture.cached_document()
|
|
718
|
+
if document is None:
|
|
719
|
+
sys.stderr.write(
|
|
720
|
+
"pocketshell: no captured usage yet. "
|
|
721
|
+
"Run `pocketshell usage --capture` (or wait for the scheduled "
|
|
722
|
+
"capture) to populate the cache.\n"
|
|
723
|
+
)
|
|
724
|
+
return 3
|
|
725
|
+
sys.stdout.write(document)
|
|
726
|
+
return 0
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _emit_reset_events() -> int:
|
|
730
|
+
"""Emit recorded reset events (#690) as a JSON document.
|
|
731
|
+
|
|
732
|
+
Always exits 0: the document is ``{"reset_events": [...]}`` (empty list
|
|
733
|
+
when no resets have been detected/logged yet), so the app can read it
|
|
734
|
+
unconditionally and surface "limits reset at <time>" when present.
|
|
735
|
+
"""
|
|
736
|
+
from pocketshell import usage_reset as _reset
|
|
737
|
+
|
|
738
|
+
sys.stdout.write(_reset.reset_events_document())
|
|
739
|
+
return 0
|
|
740
|
+
|
|
741
|
+
|
|
589
742
|
def _run_quse_json(args: Sequence[str]) -> int:
|
|
590
743
|
"""Invoke ``quse`` and normalize JSON stdout before proxying it."""
|
|
591
744
|
quse_path = _resolve_quse_binary()
|