Compare commits
13 Commits
331b90cfea
...
ea70c10ea9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea70c10ea9 | ||
|
|
a98c07ba82 | ||
|
|
3cf108f156 | ||
|
|
a970805fdc | ||
|
|
b0ad79afc1 | ||
|
|
c8f25de430 | ||
|
|
30d27e4b02 | ||
|
|
ea60e0684e | ||
|
|
35e1e4efdc | ||
|
|
a45e5434f4 | ||
|
|
af3cef8f49 | ||
|
|
fc96796dbc | ||
|
|
8dc513ee96 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,3 +12,4 @@ notes/.compile.lock
|
|||||||
cron/runs/
|
cron/runs/
|
||||||
wiki/*
|
wiki/*
|
||||||
!wiki/config.yaml
|
!wiki/config.yaml
|
||||||
|
.env
|
||||||
|
|||||||
2
SOUL.md
2
SOUL.md
@@ -33,6 +33,8 @@ Jsem [nanobot](https://github.com/HKUDS/nanobot) — lehký osobní asistent. Ml
|
|||||||
- Když existuje víc cest, vynes tradeoff nahlas místo tichého výběru jedné
|
- Když existuje víc cest, vynes tradeoff nahlas místo tichého výběru jedné
|
||||||
- Víc otázek pokládej **postupně, jednu po druhé** — u každé musí být prostor odpovědět
|
- Víc otázek pokládej **postupně, jednu po druhé** — u každé musí být prostor odpovědět
|
||||||
- **Cron tool** podporuje jen `add`, `list`, `remove` — úprava existujícího jobu vyžaduje delete + create
|
- **Cron tool** podporuje jen `add`, `list`, `remove` — úprava existujícího jobu vyžaduje delete + create
|
||||||
|
- **Exec guard blokuje `rm -rf`, `rm -r` a wildcard/multi-file `rm`** i v tmp/ — čištění dělej vyjmenovaným `rm` jednotlivých souborů + `rmdir`
|
||||||
|
- **Exec guard blokuje curl na interní hosty** („internal/private URL detected", např. Ollama endpoint na nvidia.hell) — pro HTTP na interní homelab služby použij Python skript (requests) v tmp/ přes uv
|
||||||
- Pravidla do `USER.md`/`SOUL.md`/`AGENTS.md` apod. piš **stručně a jasně** — krátké imperativní bullety, klíčové slovo tučně, bez vaty
|
- Pravidla do `USER.md`/`SOUL.md`/`AGENTS.md` apod. piš **stručně a jasně** — krátké imperativní bullety, klíčové slovo tučně, bez vaty
|
||||||
|
|
||||||
## Faktografická pravidla
|
## Faktografická pravidla
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ Nanobot defaultně **vypíná vlastní logy** (`logger.disable("nanobot")`), pro
|
|||||||
|
|
||||||
Žádná env proměnná ani config klíč pro log level mimo tyhle flagy neexistuje.
|
Žádná env proměnná ani config klíč pro log level mimo tyhle flagy neexistuje.
|
||||||
|
|
||||||
**Nasazení u nás:** `-v` přidáno do `ExecStart` v `~/.config/systemd/user/nanobot.service` na `nanobot.hell`. Logy živě: `ssh nanobot@nanobot.hell 'journalctl --user -u nanobot.service -f --no-pager'`.
|
**Nasazení u nás:** `-v` bylo v `ExecStart` v `~/.config/systemd/user/nanobot.service` na `nanobot.hell`, ale **už tam není** (ověřeno 2026-09-15: `ExecStart=/home/nanobot/.local/bin/nanobot gateway`, poslední `LLM usage` řádek v journalu je z 2026-05-27). Bez `-v` tedy **tokeny nikde nejsou** — `Processing message`/`Response to` jsou INFO a logují se dál, `LLM usage` je DEBUG a ne. Logy živě: `ssh nanobot@nanobot.hell 'journalctl --user -u nanobot.service -f --no-pager'`.
|
||||||
|
|
||||||
**Co `-v` ukáže v jednom tahu** (ověřeno na WebUI zprávě):
|
**Co `-v` ukáže v jednom tahu** (ověřeno na WebUI zprávě):
|
||||||
|
|
||||||
@@ -570,6 +570,28 @@ Daemon notifikuje **jen Telegram** (přes Bot API, deterministicky). Když task
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Exec safety guard shazuje český text — diakritika + dvojtečka = „windowsová cesta"
|
||||||
|
|
||||||
|
**Problém → příčina → pravidlo.** `exec` vrátí `Command blocked by safety guard (path outside working dir)` u příkazu, který obsahuje běžnou českou prózu. Příčina: `ExecTool._extract_absolute_paths()` (`agent/tools/shell.py`) hledá windowsové cesty regexem `(?<![A-Za-z])(?:[A-Za-z]:[^\s"'|><;]*|…)`. Lookbehind je **ASCII-only**, takže znak s diakritikou před ASCII písmenem ho neutne — `Cíl:` dá token `l:`, `Závěr:`/`směr:` dají `r:`. `Path("r:").resolve()` to rozvine vůči cwd démona (`/home/nanobot`, ne vůči workdiru příkazu) na `/home/nanobot/r:` → mimo workspace → blok. Ověřeno 2026-09-11 spuštěním nainstalovaného guardu na reálných příkazech.
|
||||||
|
|
||||||
|
**Guard jede nad raw command stringem, bez shell parseru** (`_split_shell_segments` se používá jen na allow/deny patterns). Nerozliší tedy argument od obsahu heredocu — heredoc, `--text` i `printf | pipe` s týmž textem padnou identicky. Citování a quoted heredoc **nepomáhají**.
|
||||||
|
|
||||||
|
**Pravidlo pro psaní skillů: český text nikdy nedávej do command stringu.** Vždy `write_file` do `tmp/` + předání **cesty** (`--file <path>`, nebo `< tmp/soubor`). Cesta v příkazu je neškodná; relativní cesty žádný z regexů nechytá.
|
||||||
|
|
||||||
|
**Pravidlo je od 2026-09-11 zapsané globálně** v `workspace/AGENTS.md`, sekce `## exec Tool` — tedy v system promptu při každém tahu, nezávisle na tom, jaký skill se triggerne. `AGENTS.md` je jediný z `BOOTSTRAP_FILES` (`agent/context.py:57`), který je user-ownovaný; `SOUL.md`, `USER.md` i `memory/MEMORY.md` přepisuje Dream a bundled `templates/` přepíše upgrade balíčku. **Dvě omezení:** (1) `AGENTS.md` se bere z `project_root` aktuálního tahu (`context.py:163`), takže v session scoped do `tmp/<x>` se root verze nenačte — pro běžný chat platí; (2) kdyby byl obsah identický s `templates/AGENTS.md`, soubor se do promptu tiše nedá (`context.py:179–182`). Tentýž guard byl v `AGENTS.md` předtím zdokumentovaný **dvakrát izolovaně** (sekce `## Git commit timestamps` o `date '+%H:%M:%S'` → token `H:%M:%S`, a původní věta o chybějícím workspace path), aniž by se pojmenovala společná příčina.
|
||||||
|
|
||||||
|
Co blokuje a co ne (ASCII písmeno + `:`, kde znak **před** písmenem není ASCII písmeno):
|
||||||
|
|
||||||
|
| Blokuje | Projde |
|
||||||
|
|---|---|
|
||||||
|
| `Cíl:`, `Závěr:`, `směr:`, `díl:` | `Úkol:`, `Stav:`, `Řešení:`, `Otázky:`, `Poznámka:` |
|
||||||
|
|
||||||
|
Pozor i na druhý guard nad raw stringem: `if "..\\" in cmd or "../" in cmd` → jakákoli **prozaická** zmínka `../` shodí příkaz na `path traversal detected`.
|
||||||
|
|
||||||
|
**Stav skillů.** Opraveno na `--file`: `project` (`log --file`) a `note` (`note_capture.py --file`, 2026-09-11 — byl nejrizikovější, protože bere vstup uživatele doslova, takže poznámka „Cíl: …" tiše selhala). **Zbývá dluh:** `bookmark` (český článek v heredocu) a `remind` (`edit --text "…"`). Upstream regex zůstává rozbitý — fix by chtěl unicode-aware lookbehind.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Prostředí `exec` toolu — PATH z procesu tam nedosáhne
|
## Prostředí `exec` toolu — PATH z procesu tam nedosáhne
|
||||||
|
|
||||||
`ExecTool._build_env()` (`agent/tools/shell.py`) staví prostředí subprocessu **od nuly**. Na Unixu předá jen `HOME`, `LANG`, `TERM`, `PYTHONUNBUFFERED` (+ cokoli v `tools.exec.allowedEnvKeys`). **`PATH` se z `os.environ` nekopíruje** — na Windows ano, na Unixu ne. Cokoli nastavíš v systemd unitu, `~/.profile` nebo wrapperu, `exec` neuvidí.
|
`ExecTool._build_env()` (`agent/tools/shell.py`) staví prostředí subprocessu **od nuly**. Na Unixu předá jen `HOME`, `LANG`, `TERM`, `PYTHONUNBUFFERED` (+ cokoli v `tools.exec.allowedEnvKeys`). **`PATH` se z `os.environ` nekopíruje** — na Windows ano, na Unixu ne. Cokoli nastavíš v systemd unitu, `~/.profile` nebo wrapperu, `exec` neuvidí.
|
||||||
@@ -723,7 +745,7 @@ Substituce Claude.ai "Projects". Adresář na projekt, ne jeden soubor: `workspa
|
|||||||
|
|
||||||
**Nahradil netrackovaný server-side skill** (existoval na serveru mimo tento repo, žádná zmínka v history/knowledge/decisions před tímto datem): plochý soubor `projects/<slug>.md` s frontmatterem `status`/`priority`/`created`, CLI backend (`scripts/project.py`: add/list/show/status), `switch` ukládal aktivní projekt do `my` scratchpad nástroje (viz níže). Reálná data (`projects/radio-1.md`, projekt na stříhání audio streamu Radia 1, 2026-06-09) přemigrována do nového formátu jako `projects/radio1/`. Plná historie: history.md 2026-07-22.
|
**Nahradil netrackovaný server-side skill** (existoval na serveru mimo tento repo, žádná zmínka v history/knowledge/decisions před tímto datem): plochý soubor `projects/<slug>.md` s frontmatterem `status`/`priority`/`created`, CLI backend (`scripts/project.py`: add/list/show/status), `switch` ukládal aktivní projekt do `my` scratchpad nástroje (viz níže). Reálná data (`projects/radio-1.md`, projekt na stříhání audio streamu Radia 1, 2026-06-09) přemigrována do nového formátu jako `projects/radio1/`. Plná historie: history.md 2026-07-22.
|
||||||
|
|
||||||
**Zápis do `memory.md` jde výhradně přes `skills/project/scripts/project_cli.py log`** (od 2026-09-02). Subcommandy `activate` / `log` / `list` / `new`; volat workspace-relativně `uv run skills/project/scripts/project_cli.py …`. Skript vlastní datum (systémové hodiny, Europe/Prague) a koncový newline — obojí model prokazatelně kazil: `chata/memory.md` měl dva záznamy s vymyšleným datem `2026-09-14` (zapsané 09-01) a `proxmox/memory.md` slepený append na předchozím řádku, protože `edit_file` kotva se hádala bez přečtení souboru. Text se předává **stdin quoted heredocem** (`<<'NOTE'`), ne `--text` — shell obsah neinterpretuje, takže `„"`, `'` i `"` projdou doslova (v `log/note.log` je doložený případ, kdy model `--text` argument zmršil na `...`). Stav skriptu přepisuje env `PROJECTS_DIR` (testy). Plný kontext: history.md 2026-09-02.
|
**Zápis do `memory.md` jde výhradně přes `skills/project/scripts/project_cli.py log`** (od 2026-09-02). Subcommandy `activate` / `log` / `list` / `new`; volat workspace-relativně `uv run skills/project/scripts/project_cli.py …`. Skript vlastní datum (systémové hodiny, Europe/Prague) a koncový newline — obojí model prokazatelně kazil: `chata/memory.md` měl dva záznamy s vymyšleným datem `2026-09-14` (zapsané 09-01) a `proxmox/memory.md` slepený append na předchozím řádku, protože `edit_file` kotva se hádala bez přečtení souboru. Text se předává **výhradně souborem**: `write_file` do `tmp/` + `log <slug> --file tmp/…` (od 2026-09-11). Dřív to byl stdin quoted heredoc (`<<'NOTE'`), protože `--text` model prokazatelně mrzačil (v `log/note.log` je doložený případ, kdy argument zmršil na `...`) — jenže heredoc padl na exec safety guardu, který sejme jakýkoli český text v command stringu bez ohledu na citování (viz sekce „Exec safety guard shazuje český text" výše; doloženo session `002f2196`, kdy tři varianty po sobě selhaly na `Diagnóza/směr:`). `--text` je proto z CLI **odstraněn**, stdin zůstal jako fallback. Stav skriptu přepisuje env `PROJECTS_DIR` (testy). Plný kontext: history.md 2026-09-02.
|
||||||
|
|
||||||
**Projektová data nemají strop ani konsolidaci** — `memory.md` roste neomezeně a nikdy se nekomprimuje ani nearchivuje. Velikost se řeší **výhradně na straně čtení**: `activate` nad limitem tool výsledku vypustí z výstupu nejstarší záznamy a ukáže cestu k plnému logu, soubor na disku nechá beze změny. Zamítnutá varianta: prahy 8 000 / 12 000 znaků s nabídkou konsolidace — ztráta zadaného obsahu je horší failure mode než jakákoli úspora kontextu (a čísla stála na špatném okně, viz níže).
|
**Projektová data nemají strop ani konsolidaci** — `memory.md` roste neomezeně a nikdy se nekomprimuje ani nearchivuje. Velikost se řeší **výhradně na straně čtení**: `activate` nad limitem tool výsledku vypustí z výstupu nejstarší záznamy a ukáže cestu k plnému logu, soubor na disku nechá beze změny. Zamítnutá varianta: prahy 8 000 / 12 000 znaků s nabídkou konsolidace — ztráta zadaného obsahu je horší failure mode než jakákoli úspora kontextu (a čísla stála na špatném okně, viz níže).
|
||||||
|
|
||||||
@@ -1256,3 +1278,27 @@ dostávaly `store` z `remind`). Proto všechny skilly kromě `remind` prefixují
|
|||||||
skillu (`note_capture.py`, `wiki_sync.py`) — je to nutnost, ne estetika. U kolize, které se
|
skillu (`note_capture.py`, `wiki_sync.py`) — je to nutnost, ne estetika. U kolize, které se
|
||||||
nelze vyhnout (`wiki_search.py` je i v retired `llm-wiki`), rozhoduje **pořadí** v
|
nelze vyhnout (`wiki_search.py` je i v retired `llm-wiki`), rozhoduje **pořadí** v
|
||||||
`extra-paths`. Zdroj: history 2026-09-09.
|
`extra-paths`. Zdroj: history 2026-09-09.
|
||||||
|
|
||||||
|
## Ollama usage API: co v odpovědi je a co ne
|
||||||
|
|
||||||
|
`GET https://ollama.com/api/usage` (Bearer klíč z `workspace/.env`) vrací
|
||||||
|
`limits.session` (hodinové okno) a `limits.weekly`, každé s `usage` (zlomek limitu)
|
||||||
|
a `models: [{name, request_count}]` — **pole, ne slovník**. `activity.cost` je na Pro
|
||||||
|
plánu rozbité (vždy `0.00000`). Žádné reset timestampy, žádné tokeny, žádný cost split.
|
||||||
|
|
||||||
|
**`usage` má 3 desetinná místa** (`0.072`, `0.156`) → rozlišení 0,1 % limitu.
|
||||||
|
Delta za minutu je proto skoro vždy 0 — jediná přesná veličina je `request_count`
|
||||||
|
per model. Ověřeno 2026-09-15.
|
||||||
|
|
||||||
|
## Nanobot nemá nikde per-session spotřebu tokenů
|
||||||
|
|
||||||
|
- `memory/history.jsonl` = destilát paměti z Dream procesoru (`cursor`, `timestamp`,
|
||||||
|
`content`, u části `session_key`) — **není** to účtovací log, žádné tokeny.
|
||||||
|
- `sessions/*.jsonl` = per-turn zprávy (`role`, `content`, `timestamp`, `tool_calls`,
|
||||||
|
`reasoning_content`, `latency_ms`) — **taky bez tokenů a bez názvu modelu**.
|
||||||
|
- Jediný zdroj tokenů je `LLM usage: prompt=… completion=… cached=…` v journalu, což je
|
||||||
|
**DEBUG** (vyžaduje `-v` v `ExecStart`, viz sekce o log levelu) a navíc nenese session id.
|
||||||
|
|
||||||
|
→ Atribuce spotřeby na session jde jen **časovou korelací** (timestampy v `sessions/*.jsonl`
|
||||||
|
a `Processing message from …` v journalu) proti řadě vzorků z `db/ollama_usage.sqlite`.
|
||||||
|
Ověřeno 2026-09-15, zdroj: [[plans/ollama-usage-poller.md]] sekce Revize.
|
||||||
|
|||||||
1
keep.md
1
keep.md
@@ -1,2 +1,3 @@
|
|||||||
- Chce mít filmy uložené lokálně na disku — streaming služby mění podmínky a dostupnost, chce mít obsah trvale k dispozici po zaplacení
|
- Chce mít filmy uložené lokálně na disku — streaming služby mění podmínky a dostupnost, chce mít obsah trvale k dispozici po zaplacení
|
||||||
- Má předplatné: Netflix, HBO Max, SkyShowtime, Disney+
|
- Má předplatné: Netflix, HBO Max, SkyShowtime, Disney+
|
||||||
|
- Skills (SKILL.md + skripty) vždy anglicky, pokud uživatel explicitně neřekne jinak
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
428
|
430
|
||||||
84
plans/ollama-usage-poller.md
Normal file
84
plans/ollama-usage-poller.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Ollama Usage Poller — Continuous Collection
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The `/usage` skill shows Ollama Cloud credit usage on demand. The user wants
|
||||||
|
continuous collection: poll `GET https://ollama.com/api/usage` every minute,
|
||||||
|
store a sample whenever anything changed since the last one. Goal: later
|
||||||
|
analysis of how much each nanobot session cost, and when.
|
||||||
|
|
||||||
|
Constraints found in exploration:
|
||||||
|
|
||||||
|
- The API (`ollama.com/api/usage`, Bearer key from `workspace/.env`) returns:
|
||||||
|
`limits.session.usage` (fraction of plan limit, rolling 1-hour window),
|
||||||
|
`limits.weekly.usage` (weekly window), per-model `request_count` for both
|
||||||
|
windows, `activity.cost` (broken, always $0.00000 on Pro).
|
||||||
|
- No reset timestamps, no per-model cost split, no token counts in the API.
|
||||||
|
- Existing pattern: per-minute system crontab entries running `uv run <script>`
|
||||||
|
with output to `workspace/log/<name>_cron.log` (remind, wiki-compile,
|
||||||
|
wiki-sync). Reuse this pattern.
|
||||||
|
- Session attribution later needs `memory/history.jsonl` (per-request
|
||||||
|
timestamps, token counts, session ids) — out of scope for this plan, this
|
||||||
|
plan only builds the collector + a delta report.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Collector** — `scripts/ollama_usage_poll.py` (English, stdlib only,
|
||||||
|
reuse `load_api_key` from `skills/usage/scripts/ollama_usage.py`):
|
||||||
|
- GET the API, on network/HTTP error log to stderr and exit 0
|
||||||
|
(never noisy, never blocks cron).
|
||||||
|
- SQLite `db/ollama_usage.sqlite`, table `samples`:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS samples (
|
||||||
|
ts TEXT PRIMARY KEY, -- UTC ISO 8601
|
||||||
|
session REAL NOT NULL, -- limits.session.usage fraction
|
||||||
|
weekly REAL NOT NULL, -- limits.weekly.usage fraction
|
||||||
|
models TEXT NOT NULL -- JSON: weekly {"model": count}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- Write-on-change: compare against the latest row; insert only when
|
||||||
|
session, weekly, or models JSON differ. Unchanged minutes are noise.
|
||||||
|
- Session-window reset detection (usage drops instead of rising) is
|
||||||
|
implicit — we store raw values; deltas are computed at report time.
|
||||||
|
|
||||||
|
2. **Crontab** — add system crontab entry (user's crontab, alongside the
|
||||||
|
existing ones):
|
||||||
|
```
|
||||||
|
# ollama-usage: continuous usage sampling into db/ollama_usage.sqlite
|
||||||
|
* * * * * uv run /home/nanobot/.nanobot/workspace/scripts/ollama_usage_poll.py >> /home/nanobot/.nanobot/workspace/log/ollama_usage_cron.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Report** — `scripts/ollama_usage_report.py` (English, stdlib only):
|
||||||
|
- No args: last 24 h delta summary (session/weekly spend per hour, model
|
||||||
|
request deltas).
|
||||||
|
- `--since ISO` / `--until ISO`: arbitrary window.
|
||||||
|
- Output: plain text table — for each consecutive sample pair:
|
||||||
|
`ts, Δsession %, Δweekly %, Δrequests per model`. Detect hourly reset
|
||||||
|
(session drops) and mark it as a new window boundary.
|
||||||
|
- Extend the `/usage` skill SKILL.md with a "Reports" section pointing
|
||||||
|
at this script (does not change the on-demand output format).
|
||||||
|
|
||||||
|
4. **Skill update** — `skills/usage/SKILL.md`: add a short note that
|
||||||
|
continuous sampling runs via crontab into `db/ollama_usage.sqlite` and
|
||||||
|
reports are available via `scripts/ollama_usage_report.py`.
|
||||||
|
|
||||||
|
5. **Git commit** — workspace repo, timestamped message.
|
||||||
|
|
||||||
|
## Out of scope (later)
|
||||||
|
|
||||||
|
- Session-level attribution (join with `memory/history.jsonl` token counts
|
||||||
|
per session) — separate plan once we have a few days of samples.
|
||||||
|
- Notification thresholds (e.g. Telegram alert at 80 % weekly).
|
||||||
|
- Retention/compaction of the samples table (1 row per change, negligible
|
||||||
|
size for months).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Run `uv run scripts/ollama_usage_poll.py` twice back to back → second run
|
||||||
|
writes nothing (no change), DB has 1 row.
|
||||||
|
2. Wait for a real request (or make one via nanobot) → next poll writes a
|
||||||
|
new row with changed session fraction / model counts.
|
||||||
|
3. Run the report script → delta table renders, hourly reset detected as
|
||||||
|
boundary when a session window rolls over.
|
||||||
|
4. `crontab -l` shows the new entry; after ~5 minutes `log/ollama_usage_cron.log`
|
||||||
|
is empty or minimal, DB grew.
|
||||||
@@ -8,3 +8,4 @@ Pravidlo: vynechaný den není selhání. Pásmo: podlaha 2×/týden, strop denn
|
|||||||
| 2026-09-12 | ✅ | opakování |
|
| 2026-09-12 | ✅ | opakování |
|
||||||
| 2026-09-13 | ❌ | |
|
| 2026-09-13 | ❌ | |
|
||||||
| 2026-09-14 | ✅ | |
|
| 2026-09-14 | ✅ | |
|
||||||
|
| 2026-09-15 | ✅ | + další cviky z vlastní sestavy; pravá kyčel táhne víc, kolena ztuha |
|
||||||
@@ -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-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: 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-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.
|
||||||
|
|||||||
@@ -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).
|
- 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
|
- **Chybí**: počty opakování a délky výdrží — doladit s fyzioterapeutem při
|
||||||
další návštěvě.
|
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ý
|
- **Frekvence — rozhodnuto (2026-09-11)**: denní snaha s tolerancí — vynechaný
|
||||||
den není selhání. Pásmo: podlaha 2× týdně (pevná), strop denně, cokoli
|
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
|
mezi = úspěch. Fyzicky stačilo i obden, denní cíl je kvůli návyku, ne
|
||||||
|
|||||||
117
skills/usage/SKILL.md
Normal file
117
skills/usage/SKILL.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
---
|
||||||
|
name: usage
|
||||||
|
description: >
|
||||||
|
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
|
||||||
|
|
||||||
|
Shows Ollama Cloud credit usage for the current API key.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run skills/usage/scripts/ollama_usage.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The script reads `OLLAMA_API_KEY` from the `workspace/.env` file (created by
|
||||||
|
the user). If it is missing or the key fails (401/403), tell the user —
|
||||||
|
never scrape the website.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
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: <pct> %, resets HH:MM TZ (in H h M min)
|
||||||
|
Weekly: <pct> %, resets in Y days
|
||||||
|
Models (request count, weekly window):
|
||||||
|
<model>: <count>
|
||||||
|
```
|
||||||
|
|
||||||
|
The per-model breakdown lives only in the "Models" section — never inline
|
||||||
|
on the Session/Weekly lines.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
A cron job runs `scripts/ollama_usage_poll.py` every minute and appends to
|
||||||
|
`db/ollama_usage.sqlite` whenever anything changed (table `samples`; table `meta`
|
||||||
|
records every poll, so a gap can be told apart from a failed poll).
|
||||||
|
|
||||||
|
For a delta report over that data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run skills/usage/scripts/ollama_usage_report.py [--since ISO] [--until ISO]
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
`Authorization: Bearer <key>` (verified 2026-09; issue #15132 is stale).
|
||||||
|
- `limits.*.usage` is a fraction of the plan limit (× 100 = % as on the
|
||||||
|
dashboard).
|
||||||
|
- `activity.cost` returns $0.00000 on the Pro plan — broken, omitted from
|
||||||
|
the output.
|
||||||
|
- The `~/.ollama/id_ed25519` key does not work — only an API key minted at
|
||||||
|
ollama.com/settings/keys.
|
||||||
175
skills/usage/scripts/ollama_usage.py
Normal file
175
skills/usage/scripts/ollama_usage.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""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 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()
|
||||||
|
env_file = WORKSPACE / ".env"
|
||||||
|
if env_file.is_file():
|
||||||
|
for line in env_file.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("OLLAMA_API_KEY="):
|
||||||
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
|
sys.exit("OLLAMA_API_KEY not found (neither env nor workspace/.env)")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
return nxt - now
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_delta_short(td: timedelta) -> str:
|
||||||
|
"""Human countdown, dashboard style: '1 hour', '55 minutes', '6 days'."""
|
||||||
|
total = td.total_seconds()
|
||||||
|
if total >= 86400:
|
||||||
|
return f"{int(total // 86400)} days"
|
||||||
|
if total >= 3600:
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
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:
|
||||||
|
sys.exit(f"ollama.com unreachable: {e.reason}")
|
||||||
|
|
||||||
|
limits = data.get("limits", {})
|
||||||
|
session = limits.get("session", {})
|
||||||
|
weekly = limits.get("weekly", {})
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
print("Ollama Cloud usage")
|
||||||
|
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']}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
97
skills/usage/scripts/ollama_usage_poll.py
Normal file
97
skills/usage/scripts/ollama_usage_poll.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sample Ollama Cloud usage into db/ollama_usage.sqlite. Run from cron every minute.
|
||||||
|
|
||||||
|
Writes a `samples` row only when something changed; `meta` records every poll so a
|
||||||
|
gap in `samples` can be told apart from a poll that failed or never ran.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from ollama_usage import DB_PATH, fetch_usage
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS samples (
|
||||||
|
ts TEXT PRIMARY KEY,
|
||||||
|
session_usage REAL NOT NULL,
|
||||||
|
weekly_usage REAL NOT NULL,
|
||||||
|
session_models TEXT NOT NULL,
|
||||||
|
weekly_models TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
last_ts TEXT NOT NULL,
|
||||||
|
last_status TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_models(models: list[dict]) -> str:
|
||||||
|
"""Stable JSON for change detection — the API returns an unordered list."""
|
||||||
|
return json.dumps(
|
||||||
|
{m["name"]: m["request_count"] for m in models},
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def connect() -> sqlite3.Connection:
|
||||||
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def record_poll(conn: sqlite3.Connection, ts: str, status: str) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO meta (id, last_ts, last_status) VALUES (1, ?, ?) "
|
||||||
|
"ON CONFLICT(id) DO UPDATE SET last_ts = excluded.last_ts, "
|
||||||
|
"last_status = excluded.last_status",
|
||||||
|
(ts, status),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
).fetchone()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
now = datetime.now(UTC).replace(microsecond=0).isoformat()
|
||||||
|
conn = connect()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = fetch_usage()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
record_poll(conn, now, f"http_{e.code}")
|
||||||
|
print(f"{now} ollama.com API error: HTTP {e.code}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
record_poll(conn, now, "unreachable")
|
||||||
|
print(f"{now} ollama.com unreachable: {e.reason}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
limits = data.get("limits", {})
|
||||||
|
session = limits.get("session", {})
|
||||||
|
weekly = limits.get("weekly", {})
|
||||||
|
sample = (
|
||||||
|
session.get("usage", 0.0),
|
||||||
|
weekly.get("usage", 0.0),
|
||||||
|
canonical_models(session.get("models", [])),
|
||||||
|
canonical_models(weekly.get("models", [])),
|
||||||
|
)
|
||||||
|
|
||||||
|
if sample != latest_sample(conn):
|
||||||
|
conn.execute("INSERT INTO samples VALUES (?, ?, ?, ?, ?)", (now, *sample))
|
||||||
|
record_poll(conn, now, "ok")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
141
skills/usage/scripts/ollama_usage_report.py
Normal file
141
skills/usage/scripts/ollama_usage_report.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Delta report over db/ollama_usage.sqlite collected by ollama_usage_poll.py.
|
||||||
|
|
||||||
|
Request counts are the exact axis; `limits.*.usage` has a resolution of 0.1 %,
|
||||||
|
so per-sample percentage deltas are mostly quantization noise and are shown only
|
||||||
|
as a running level, plus one aggregate for the whole window.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--since", help="ISO 8601 UTC start (default: 24 h ago)")
|
||||||
|
parser.add_argument("--until", help="ISO 8601 UTC end (default: now)")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_window(args: argparse.Namespace) -> tuple[str, str]:
|
||||||
|
now = datetime.now(UTC).replace(microsecond=0)
|
||||||
|
since = args.since or (now - DEFAULT_WINDOW).isoformat()
|
||||||
|
until = args.until or now.isoformat()
|
||||||
|
return since, until
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
||||||
|
return {n: d for n, d in sorted(deltas.items()) if d}
|
||||||
|
|
||||||
|
|
||||||
|
def format_deltas(deltas: dict[str, int]) -> str:
|
||||||
|
return ", ".join(f"{name} +{count}" for name, count in deltas.items()) or "-"
|
||||||
|
|
||||||
|
|
||||||
|
def minutes_between(earlier: str, later: str) -> float:
|
||||||
|
delta = datetime.fromisoformat(later) - datetime.fromisoformat(earlier)
|
||||||
|
return delta.total_seconds() / 60
|
||||||
|
|
||||||
|
|
||||||
|
def print_rows(samples: list[Sample]) -> dict[str, int]:
|
||||||
|
total: dict[str, int] = {}
|
||||||
|
for previous, current in itertools.pairwise(samples):
|
||||||
|
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.weekly_models), json.loads(current.weekly_models))
|
||||||
|
for name, count in deltas.items():
|
||||||
|
total[name] = total.get(name, 0) + count
|
||||||
|
print(
|
||||||
|
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_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.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)}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_poll_status(conn: sqlite3.Connection) -> None:
|
||||||
|
row = conn.execute("SELECT last_ts, last_status FROM meta WHERE id = 1").fetchone()
|
||||||
|
if row:
|
||||||
|
print(f"Last poll: {row[0]} ({row[1]})")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if not DB_PATH.is_file():
|
||||||
|
sys.exit(f"no samples yet: {DB_PATH} does not exist")
|
||||||
|
|
||||||
|
args = parse_args()
|
||||||
|
since, until = resolve_window(args)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
samples = load_samples(conn, since, until)
|
||||||
|
|
||||||
|
if len(samples) < 2:
|
||||||
|
print(f"Not enough samples between {since} and {until}.")
|
||||||
|
print_poll_status(conn)
|
||||||
|
return
|
||||||
|
|
||||||
|
total = print_rows(samples)
|
||||||
|
print_summary(samples, total)
|
||||||
|
print_window(conn)
|
||||||
|
print_poll_status(conn)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user