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/
|
||||
wiki/*
|
||||
!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é
|
||||
- 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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ě):
|
||||
|
||||
@@ -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
|
||||
|
||||
`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.
|
||||
|
||||
**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).
|
||||
|
||||
@@ -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
|
||||
nelze vyhnout (`wiki_search.py` je i v retired `llm-wiki`), rozhoduje **pořadí** v
|
||||
`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í
|
||||
- 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.
|
||||
@@ -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 | ✅ | |
|
||||
| 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-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.
|
||||
|
||||
@@ -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
|
||||
|
||||
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