From ea70c10ea98f030e424b407184a65e376cd97a01 Mon Sep 17 00:00:00 2001 From: lachtan Date: Tue, 15 Sep 2026 10:15:24 +0200 Subject: [PATCH] runtime zmeny --- .gitignore | 1 + projects/life/artifacts/trenink-log.md | 3 +- projects/life/memory.md | 3 + projects/life/state.md | 3 + skills/usage/SKILL.md | 68 +++++++-- skills/usage/scripts/ollama_usage.py | 146 +++++++++++++++----- skills/usage/scripts/ollama_usage_poll.py | 19 +-- skills/usage/scripts/ollama_usage_report.py | 70 ++++++---- 8 files changed, 225 insertions(+), 88 deletions(-) diff --git a/.gitignore b/.gitignore index 01af7f6..f3bfe16 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ notes/.compile.lock cron/runs/ wiki/* !wiki/config.yaml +.env diff --git a/projects/life/artifacts/trenink-log.md b/projects/life/artifacts/trenink-log.md index d6acffc..da57b1c 100644 --- a/projects/life/artifacts/trenink-log.md +++ b/projects/life/artifacts/trenink-log.md @@ -7,4 +7,5 @@ Pravidlo: vynechaný den není selhání. Pásmo: podlaha 2×/týden, strop denn | 2026-09-11 | ✅ | první trénink po fyzio (10.9.) | | 2026-09-12 | ✅ | opakování | | 2026-09-13 | ❌ | | -| 2026-09-14 | ✅ | | \ No newline at end of file +| 2026-09-14 | ✅ | | +| 2026-09-15 | ✅ | + další cviky z vlastní sestavy; pravá kyčel táhne víc, kolena ztuha | \ No newline at end of file diff --git a/projects/life/memory.md b/projects/life/memory.md index 489fe9a..6a60c42 100644 --- a/projects/life/memory.md +++ b/projects/life/memory.md @@ -24,3 +24,6 @@ - 2026-09-12: Opakování fyzio cviků (kyčel/pánev) — další trénink po zahájení 11.9., adherence drží v rámci zvoleného pásma (podlaha 2×/týden, cíl denně). - 2026-09-14: Založen tréninkový log (artifacts/trenink-log.md) — tabulka datum/trénink/poznámka pro fyzio cviky. Vynechaný den 13.9. bez komentáře (v rámci tolerance), 14.9. cvičil. - 2026-09-14: Doplněno do cílů projektu (prompt.md): dobrá mobilita hlavně kyčle, pohyb bez bolesti, ideálně pravidelné cvičení — vedle změny váhy a kondice. +- 2026-09-15: Cvičil fyzio cviky — třetí tréninkový den za čtyři dny (11., 12., 14., 15.9.), v rámci zvoleného pásma. +- 2026-09-15: Stav kyčle po 4 dnech cvičení: asymetrie přetrvává — pravá strana táhne o dost víc, přitahování kolen jde obecně ztuha. K fyzio sestavě zařadil i další cviky ze své obvyklé sestavy. Rozhodnutí: nová návštěva fyzioterapeutky cca 3–4 týdny po první (10.9.), tedy začátek října — v souladu s klinickou praxí (reassessment 2–3 týdny po zahájení, pak 3–4 týdny; ROM adaptace trvá týdny). Na kontrole doladit počty opakování a výdrže u cviků. +- 2026-09-15: Upřesnění k rozšíření sestavy: další přidané cviky jsou taky protahovací — celý trénink tak zůstává čistě mobilizační, žádná zátěž navíc. diff --git a/projects/life/state.md b/projects/life/state.md index b9175c6..d791b62 100644 --- a/projects/life/state.md +++ b/projects/life/state.md @@ -52,6 +52,9 @@ Denní vážení, vnímaná kondice, jestli mě něco bolí. - Cviky uložené v `artifacts/fyzio-cviky-kycele.md` (rekonstrukce z paměti). - **Chybí**: počty opakování a délky výdrží — doladit s fyzioterapeutem při další návštěvě. +- **Kontrola naplánovaná**: ~3–4 týdny po první návštěvě, tedy začátek října. +- **Stav (15.9.)**: asymetrie přetrvává — pravá strana táhne víc, přitahování + kolen ztuha. ROM adaptace trvá týdny, čekat zlepšení. - **Frekvence — rozhodnuto (2026-09-11)**: denní snaha s tolerancí — vynechaný den není selhání. Pásmo: podlaha 2× týdně (pevná), strop denně, cokoli mezi = úspěch. Fyzicky stačilo i obden, denní cíl je kvůli návyku, ne diff --git a/skills/usage/SKILL.md b/skills/usage/SKILL.md index 6d4a4be..63fe7cc 100644 --- a/skills/usage/SKILL.md +++ b/skills/usage/SKILL.md @@ -1,10 +1,9 @@ --- name: usage description: > - Show Ollama Cloud credit/usage spent via the ollama.com usage API, and - report continuously sampled usage history. Triggers on: "/usage", - "ollama usage", "how much credit", "how much have I used up", - "credits left", "quota", "usage report", "usage history". + How much of the Ollama Cloud plan has been spent — current session and weekly + usage per model, and reports over the continuously sampled history. + Triggers on: "ollama usage", "usage history". --- # Usage @@ -27,9 +26,9 @@ Format (script prints it, present it to the user as-is — same lines, same order; translate the labels into the user's language, keep the numbers exact; no extra model info on the Session/Weekly lines): -``` +```text Ollama Cloud usage -Session: %, resets in X hours +Session: %, resets HH:MM TZ (in H h M min) Weekly: %, resets in Y days Models (request count, weekly window): : @@ -38,14 +37,53 @@ Models (request count, weekly window): The per-model breakdown lives only in the "Models" section — never inline on the Session/Weekly lines. -## Reset times — derived, not from the API +Times are printed in the **server's local zone** (`Europe/Prague`), taken from +the system — no zone is hardcoded. The session line loses its reset clause when +the history holds no rollover to anchor the window; that is correct output, +not a failure. -`/api/usage` contains **no reset timestamps** (they exist only in the HTML -UI). The script computes them: session = until the next full hour UTC (the -dashboard shows "Resets in 1 hour"), weekly = until the next Monday 00:00 -UTC. Rationale: session usage climbed in real time during testing (rolling -window), weekly changes slowly — consistent with hourly/weekly windows. -If the window turns out not to be calendar-based, fix `until_next_*`. +## Reset times + +`/api/usage` carries **no reset timestamps**, neither in the body nor in the +response headers (re-checked 2026-09-15). Both are derived. + +**Weekly:** next Monday 00:00 UTC, `until_next_monday()`. Matches the dashboard. + +**Session: a 5-hour window anchored by the first request after the previous one +ran out** — not a fixed grid. The length comes from +[ollama.com/blog/transparent-pricing](https://ollama.com/blog/transparent-pricing): +the new plans dropped the "5-hour or weekly limits" this key still has. + +How the anchoring was established on 2026-09-15: usage sat unchanged at +0.077/21 requests through 05:00 UTC — a fixed grid would have zeroed it there +and the poller would have recorded it — and only reset when a request arrived +at 06:00, after a 93-minute pause. Reconstructing the agent's activity gives a +consistent chain: window 00:00–05:00, then 06:00–11:00, each opened by the +first request after the previous expiry. A fixed grid would additionally +require that request to land exactly on a boundary by chance. + +So `session_window_end()` takes the **newest rollover in `samples`** and adds +5 h. A rollover sample marks the start of a new window, not a boundary that was +due anyway, which is why nothing is ever extrapolated past it: once the window +runs out, the output says the next one starts with the next request rather than +naming a time. + +That it is a window and not a rolling counter was measured too — usage dropped +from 0.077/21 to 0.0/`{}` at once; a rolling counter decays gradually. + +**The reset is never guessed.** An earlier version assumed a calendar hour and +printed "resets in 31 minutes" while the dashboard said "Resets in 2 hours" — +a confident wrong number is worse than none. With no rollover in the history, +the Session line carries the percentage alone. + +Do not compare our countdown against the dashboard's to the hour: the dashboard +rounds an unknown way (it showed "4 hours" and "3 hours" seven minutes apart), +which is why the output prints the wall-clock time too. + +**If the model is wrong, the report shows it.** `Rollover gap:` lines compare +consecutive rollovers against the 5 h window — gaps longer than the block +confirm request-anchoring, a gap exactly equal to it across a long idle stretch +would point back to a fixed grid. ## Continuous sampling @@ -63,6 +101,10 @@ Default window is the last 24 hours. Per-model **request counts** are the exact figure there — `limits.*.usage` has a resolution of 0.1 %, so short-interval percentage deltas are noise. +The report ends with `Rollover gap:` lines and a `Session window:` line — +when the window in progress started, when it ends, and how the observed +rollovers line up against the 5 h length. + ## Notes - Endpoint: `GET https://ollama.com/api/usage`, header diff --git a/skills/usage/scripts/ollama_usage.py b/skills/usage/scripts/ollama_usage.py index 176eca4..bc6cf3d 100644 --- a/skills/usage/scripts/ollama_usage.py +++ b/skills/usage/scripts/ollama_usage.py @@ -1,25 +1,48 @@ #!/usr/bin/env python3 -"""Ollama Cloud usage — GET https://ollama.com/api/usage with Bearer key.""" +"""Ollama Cloud usage — GET https://ollama.com/api/usage with Bearer key. + +Also the shared base for the poller and the report: DB location, the sample +row shape, and the session-window arithmetic described below. +""" from __future__ import annotations +import itertools import json import os +import sqlite3 import sys import urllib.error import urllib.request -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path +from typing import NamedTuple API_URL = "https://ollama.com/api/usage" +WORKSPACE = Path(__file__).resolve().parents[3] +DB_PATH = WORKSPACE / "db" / "ollama_usage.sqlite" + +SAMPLE_COLUMNS = "ts, session_usage, weekly_usage, session_models, weekly_models" + +# Legacy plans bill in 5-hour session windows; see ollama.com/blog/transparent-pricing +# ("no 5-hour or weekly limits" is what the *new* plans dropped). +SESSION_BLOCK = timedelta(hours=5) + + +class Sample(NamedTuple): + ts: str + session_usage: float + weekly_usage: float + session_models: str + weekly_models: str + def load_api_key() -> str: env = os.environ.get("OLLAMA_API_KEY") if env: return env.strip() - # workspace root = three levels above this script - env_file = Path(__file__).resolve().parents[3] / ".env" + env_file = WORKSPACE / ".env" if env_file.is_file(): for line in env_file.read_text().splitlines(): line = line.strip() @@ -28,17 +51,10 @@ def load_api_key() -> str: sys.exit("OLLAMA_API_KEY not found (neither env nor workspace/.env)") -def until_next_full_hour(now: datetime) -> timedelta: - nxt = (now.replace(minute=0, second=0, microsecond=0) - + timedelta(hours=1)) - return nxt - now - - def until_next_monday(now: datetime) -> timedelta: # days until Monday (weekday(): Mon == 0) days = (7 - now.weekday()) % 7 or 7 - nxt = (now.replace(hour=0, minute=0, second=0, microsecond=0) - + timedelta(days=days)) + nxt = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=days) return nxt - now @@ -48,18 +64,92 @@ def fmt_delta_short(td: timedelta) -> str: if total >= 86400: return f"{int(total // 86400)} days" if total >= 3600: - return f"{round(td.total_seconds() / 3600)} hours" - return f"{max(1, round(td.total_seconds() / 60))} minutes" + return f"{round(total / 3600)} hours" + return f"{max(1, round(total / 60))} minutes" + + +def fmt_countdown(td: timedelta) -> str: + """Exact countdown: '3 h 55 min'. The dashboard rounds; we do not.""" + hours, minutes = divmod(int(td.total_seconds() // 60), 60) + return f"{hours} h {minutes} min" if hours else f"{minutes} min" + + +def is_window_reset(previous: Sample, current: Sample) -> bool: + """Session window rolled over: its usage or its request total went down.""" + previous_total = sum(json.loads(previous.session_models).values()) + current_total = sum(json.loads(current.session_models).values()) + return current.session_usage < previous.session_usage or current_total < previous_total + + +def load_samples(conn: sqlite3.Connection, since: str | None = None, until: str | None = None) -> list[Sample]: + if since is None and until is None: + rows = conn.execute(f"SELECT {SAMPLE_COLUMNS} FROM samples ORDER BY ts") + else: + rows = conn.execute( + f"SELECT {SAMPLE_COLUMNS} FROM samples WHERE ts >= ? AND ts <= ? ORDER BY ts", + (since, until), + ) + return [Sample(*row) for row in rows] + + +def window_rollovers(samples: list[Sample]) -> list[datetime]: + """Every sample where the session window rolled over, oldest first. + + The window is not on a fixed grid: it starts with the first request after + the previous one ran out. On 2026-09-15 usage sat unchanged through 05:00 + UTC and only reset once a request arrived at 06:00 — so a rollover sample + marks the *start of a new window*, not a boundary that was due anyway. + """ + return [ + datetime.fromisoformat(current.ts) + for previous, current in itertools.pairwise(samples) + if is_window_reset(previous, current) + ] + + +def session_window_end() -> datetime | None: + """End of the window in progress, or None when the history cannot show it.""" + if not DB_PATH.is_file(): + return None + conn = sqlite3.connect(DB_PATH) + try: + rollovers = window_rollovers(load_samples(conn)) + finally: + conn.close() + return rollovers[-1] + SESSION_BLOCK if rollovers else None + + +def fetch_usage() -> dict: + request = urllib.request.Request( + API_URL, + headers={ + "Authorization": f"Bearer {load_api_key()}", + "Accept": "application/json", + }, + ) + with urllib.request.urlopen(request, timeout=15) as response: + return json.load(response) + + +def print_session(usage: float, now: datetime) -> None: + percent = f"Session: {usage * 100:.1f} %" + end = session_window_end() + + if end is None: + # No rollover recorded yet — the window's start is simply unknown. + print(percent) + elif end <= now: + # The window ran out; the next one only begins with the next request, + # so there is no time to count down to. + print(f"{percent}, window expired — the next one starts with the next request") + else: + local = end.astimezone() + print(f"{percent}, resets {local:%H:%M %Z} (in {fmt_countdown(end - now)})") def main() -> None: - req = urllib.request.Request(API_URL, headers={ - "Authorization": f"Bearer {load_api_key()}", - "Accept": "application/json", - }) try: - with urllib.request.urlopen(req, timeout=15) as resp: - data = json.load(resp) + data = fetch_usage() except urllib.error.HTTPError as e: sys.exit(f"ollama.com API error: HTTP {e.code}") except urllib.error.URLError as e: @@ -68,24 +158,18 @@ def main() -> None: limits = data.get("limits", {}) session = limits.get("session", {}) weekly = limits.get("weekly", {}) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) print("Ollama Cloud usage") - print( - f'Session: {session.get("usage", 0) * 100:.1f} %, ' - f'resets in {fmt_delta_short(until_next_full_hour(now))}' - ) - print( - f'Weekly: {weekly.get("usage", 0) * 100:.1f} %, ' - f'resets in {fmt_delta_short(until_next_monday(now))}' - ) + print_session(session.get("usage", 0.0), now) + print(f"Weekly: {weekly.get('usage', 0) * 100:.1f} %, resets in {fmt_delta_short(until_next_monday(now))}") models = weekly.get("models", []) if models: print("Models (request count, weekly window):") for m in models: - print(f' {m["name"]}: {m["request_count"]}') + print(f" {m['name']}: {m['request_count']}") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/skills/usage/scripts/ollama_usage_poll.py b/skills/usage/scripts/ollama_usage_poll.py index 2cfcb13..329febb 100644 --- a/skills/usage/scripts/ollama_usage_poll.py +++ b/skills/usage/scripts/ollama_usage_poll.py @@ -11,14 +11,9 @@ import json import sqlite3 import sys import urllib.error -import urllib.request from datetime import UTC, datetime -from pathlib import Path -from ollama_usage import API_URL, load_api_key - -WORKSPACE = Path(__file__).resolve().parents[3] -DB_PATH = WORKSPACE / "db" / "ollama_usage.sqlite" +from ollama_usage import DB_PATH, fetch_usage SCHEMA = """ CREATE TABLE IF NOT EXISTS samples ( @@ -36,15 +31,6 @@ CREATE TABLE IF NOT EXISTS meta ( """ -def fetch_usage() -> dict: - request = urllib.request.Request(API_URL, headers={ - "Authorization": f"Bearer {load_api_key()}", - "Accept": "application/json", - }) - with urllib.request.urlopen(request, timeout=15) as response: - return json.load(response) - - def canonical_models(models: list[dict]) -> str: """Stable JSON for change detection — the API returns an unordered list.""" return json.dumps( @@ -72,8 +58,7 @@ def record_poll(conn: sqlite3.Connection, ts: str, status: str) -> None: def latest_sample(conn: sqlite3.Connection) -> tuple | None: row = conn.execute( - "SELECT session_usage, weekly_usage, session_models, weekly_models " - "FROM samples ORDER BY ts DESC LIMIT 1" + "SELECT session_usage, weekly_usage, session_models, weekly_models FROM samples ORDER BY ts DESC LIMIT 1" ).fetchone() return row diff --git a/skills/usage/scripts/ollama_usage_report.py b/skills/usage/scripts/ollama_usage_report.py index bc5f0d3..bd892fd 100644 --- a/skills/usage/scripts/ollama_usage_report.py +++ b/skills/usage/scripts/ollama_usage_report.py @@ -14,10 +14,16 @@ import json import sqlite3 import sys from datetime import UTC, datetime, timedelta -from pathlib import Path -WORKSPACE = Path(__file__).resolve().parents[3] -DB_PATH = WORKSPACE / "db" / "ollama_usage.sqlite" +from ollama_usage import ( + DB_PATH, + SESSION_BLOCK, + Sample, + fmt_countdown, + is_window_reset, + load_samples, + window_rollovers, +) DEFAULT_WINDOW = timedelta(hours=24) GAP_MINUTES = 15 @@ -37,14 +43,6 @@ def resolve_window(args: argparse.Namespace) -> tuple[str, str]: return since, until -def load_samples(conn: sqlite3.Connection, since: str, until: str) -> list[tuple]: - return conn.execute( - "SELECT ts, session_usage, weekly_usage, session_models, weekly_models " - "FROM samples WHERE ts >= ? AND ts <= ? ORDER BY ts", - (since, until), - ).fetchall() - - def count_deltas(previous: dict[str, int], current: dict[str, int]) -> dict[str, int]: names = set(previous) | set(current) deltas = {n: current.get(n, 0) - previous.get(n, 0) for n in names} @@ -60,37 +58,56 @@ def minutes_between(earlier: str, later: str) -> float: return delta.total_seconds() / 60 -def is_window_reset(previous: tuple, current: tuple) -> bool: - """Session window rolled over: its usage or its request total went down.""" - previous_total = sum(json.loads(previous[3]).values()) - current_total = sum(json.loads(current[3]).values()) - return current[1] < previous[1] or current_total < previous_total - - -def print_rows(samples: list[tuple]) -> dict[str, int]: +def print_rows(samples: list[Sample]) -> dict[str, int]: total: dict[str, int] = {} for previous, current in itertools.pairwise(samples): - gap = minutes_between(previous[0], current[0]) + gap = minutes_between(previous.ts, current.ts) if gap > GAP_MINUTES: print(f" … {gap:.0f} min with no recorded change (idle or poller down)") if is_window_reset(previous, current): print(" ── session window reset ──") - deltas = count_deltas(json.loads(previous[4]), json.loads(current[4])) + deltas = count_deltas(json.loads(previous.weekly_models), json.loads(current.weekly_models)) for name, count in deltas.items(): total[name] = total.get(name, 0) + count print( - f"{current[0]} session {current[1] * 100:5.1f} % " - f"weekly {current[2] * 100:5.1f} % {format_deltas(deltas)}" + f"{current.ts} session {current.session_usage * 100:5.1f} % " + f"weekly {current.weekly_usage * 100:5.1f} % {format_deltas(deltas)}" ) return total -def print_summary(samples: list[tuple], total: dict[str, int]) -> None: +def print_window(conn: sqlite3.Connection) -> None: + """The session window in progress, plus the gaps that test how it is anchored. + + Consecutive rollovers exactly SESSION_BLOCK apart would mean a fixed grid; + longer gaps mean the window is anchored by the first request after the + previous one ran out, which is what the data so far shows. + """ + rollovers = window_rollovers(load_samples(conn)) + if not rollovers: + print("Session window: no rollover recorded yet") + return + + for previous, current in itertools.pairwise(rollovers): + gap = current - previous + verdict = "= block" if gap == SESSION_BLOCK else "> block (window is request-anchored)" + print(f"Rollover gap: {previous:%m-%d %H:%M} → {current:%m-%d %H:%M} = {fmt_countdown(gap)} {verdict}") + + started = rollovers[-1].astimezone() + ends = (rollovers[-1] + SESSION_BLOCK).astimezone() + now = datetime.now(UTC) + remaining = ( + f"in {fmt_countdown(ends - now)}" if ends > now else "expired; next window starts with the next request" + ) + print(f"Session window: started {started:%m-%d %H:%M %Z}, ends {ends:%m-%d %H:%M %Z} ({remaining})") + + +def print_summary(samples: list[Sample], total: dict[str, int]) -> None: first, last = samples[0], samples[-1] print() - print(f"Window: {first[0]} → {last[0]} ({len(samples)} samples)") - print(f"Weekly usage: {first[2] * 100:.1f} % → {last[2] * 100:.1f} %") + print(f"Window: {first.ts} → {last.ts} ({len(samples)} samples)") + print(f"Weekly usage: {first.weekly_usage * 100:.1f} % → {last.weekly_usage * 100:.1f} %") print(f"Requests: {format_deltas(total)}") @@ -116,6 +133,7 @@ def main() -> None: total = print_rows(samples) print_summary(samples, total) + print_window(conn) print_poll_status(conn)