Compare commits

..

13 Commits

Author SHA1 Message Date
lachtan
ea70c10ea9 runtime zmeny 2026-09-15 10:15:24 +02:00
nanobot
a98c07ba82 usage: make SKILL.md English-only (triggers, output-language rule) 2026-09-15 06:24:51 +02:00
nanobot
3cf108f156 develop: sync knowledge.md (ollama usage API shape, no per-session tokens) 2026-09-15 06:22:58 +02:00
nanobot
a970805fdc usage: continuous Ollama Cloud usage sampling + delta report
- scripts/ollama_usage_poll.py: per-minute cron sample into db/ollama_usage.sqlite,
  write-on-change; meta table records every poll so a data gap can be told apart
  from a failed or missed poll
- scripts/ollama_usage_report.py: delta report keyed on per-model request_count
  (limits.*.usage has 0.1 % resolution, short-interval deltas are noise)
- SKILL.md: Continuous sampling section
2026-09-15 06:22:53 +02:00
nanobot
b0ad79afc1 dream: periodic memory consolidation
SOUL.md: +2 -0
1 file changed, 2 insertions(+), 0 deletions(-)

```diff
--- SOUL.md
+++ SOUL.md
@@ -33,6 +33,8 @@
 - 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
 - **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
 
 ## Faktografická pravidla
```
2026-09-15 04:49:30 +02:00
lachtan
c8f25de430 nanobot: 2026-09-14 21:20:00 — plan: ollama usage poller 2026-09-14 21:14:13 +02:00
lachtan
30d27e4b02 nanobot: 2026-09-14 21:16:00 — usage: models only in Models section, not inline 2026-09-14 21:11:06 +02:00
lachtan
ea60e0684e nanobot: 2026-09-14 21:10:00 — keep: skills always English 2026-09-14 21:09:00 +02:00
lachtan
35e1e4efdc nanobot: 2026-09-14 21:12:30 — usage: skill + script fully English 2026-09-14 21:08:14 +02:00
lachtan
a45e5434f4 nanobot: 2026-09-14 21:07:55 — usage: resets countdown + per-model list 2026-09-14 21:07:56 +02:00
lachtan
af3cef8f49 nanobot: 2026-09-14 21:05:11 — usage: usage je frakce limitu, ne $ 2026-09-14 21:00:32 +02:00
lachtan
fc96796dbc nanobot: 2026-09-14 20:58:30 — untrack .env (secret in repo) 2026-09-14 20:58:05 +02:00
lachtan
8dc513ee96 nanobot: 2026-09-14 20:57:58 2026-09-14 20:58:01 +02:00
13 changed files with 675 additions and 4 deletions

1
.gitignore vendored
View File

@@ -12,3 +12,4 @@ notes/.compile.lock
cron/runs/ cron/runs/
wiki/* wiki/*
!wiki/config.yaml !wiki/config.yaml
.env

View File

@@ -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

View File

@@ -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:179182`). 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.

View File

@@ -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

View File

@@ -1 +1 @@
428 430

View 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.

View File

@@ -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 |

View File

@@ -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 34 týdny po první (10.9.), tedy začátek října — v souladu s klinickou praxí (reassessment 23 týdny po zahájení, pak 34 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.

View File

@@ -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á**: ~34 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
View 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:0005:00, then 06:0011: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.

View 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()

View 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()

View 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()