Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
85ceba3
Charts: draw superscripts and other glyphs the built-in font lacks as…
claude Nov 2, 2026
2956087
Pie legend: whole-number values instead of percentages (the donut sho…
claude Nov 2, 2026
7cb0ac4
Pie: unit in legend and total from the title's bracket; subtitle matc…
claude Nov 2, 2026
b6280b8
Pie: larger donut, donut and legend centred together
claude Nov 2, 2026
bb03d55
Merge main into branch after the squash-merge of #48
claude Nov 4, 2026
c3b375b
OpenRouter: end a chat answer with its web-search sources when it lin…
claude Nov 4, 2026
4307dc8
OpenRouter: cite sources as [n] links after the cited sentence
claude Nov 4, 2026
3cc0e2b
Config: drop LOG_WIDTH, rename BOT_TZ/HISTORY_LIMIT/TELEGRAM_PRELOAD_…
claude Nov 4, 2026
02f8cbb
Docs: ADMIN_ONLY says what on and off do; configuration tables get a …
claude Nov 4, 2026
cfd93f8
OpenRouter default model: deepseek/deepseek-v4.1-flash-1017
claude Nov 4, 2026
1412dc1
SEARCH_MODEL defaults to MODEL with :online added
claude Nov 4, 2026
07a0624
OpenRouter: debug-log what the citations carry and how many [n] marke…
claude Nov 4, 2026
f23ca7f
Cite web-search sources as [n] after the sentence each snippet fits best
claude Nov 4, 2026
4806c05
Ask the model for inline [n] source links; drop the Sources list
claude Nov 4, 2026
dbfe333
Cite: debug-log why no [n] markers were added and which model links w…
claude Nov 4, 2026
be66672
NOT_TICKERS env var: extra words never linked as tickers
claude Nov 4, 2026
b371c23
Docs: configuration.md ends after the Advanced and optional table
claude Nov 4, 2026
6a4b748
Cite: add [n] for the pages the model did not cite, then number all i…
claude Nov 4, 2026
9347579
Ticker links: only marked forms and known tickers, never bare words
claude Nov 5, 2026
3e75889
Ticker links: fixes from an adversarial review
claude Nov 5, 2026
c361600
Known tickers: Wikipedia's action API with a contact User-Agent, and …
claude Nov 5, 2026
7891c66
Test the ASX 200 page layout (Code column, number codes like 360)
claude Nov 5, 2026
9810d08
Known tickers: read the NASDAQ-100 from "List of NASDAQ-100 companies"
claude Nov 5, 2026
2aaf21f
FINANCE_LINK_PROVIDER: ticker links to Yahoo Finance (default) or Goo…
claude Nov 5, 2026
d7d9874
FINANCE_LINK_TARGET (was _PROVIDER); Google links name the US exchange
claude Nov 5, 2026
ac16638
FINANCE_LINK_TARGET=tradingview; quote URLs move to lib/quote_links.py
claude Nov 5, 2026
5ea6e16
/usage: requests and cost per kind under each period
claude Nov 5, 2026
59a5b5d
Menu lists /usage (was /tokens); docs name /usage first
claude Nov 5, 2026
8b46fb3
Holding news: one daily search for everyone's holdings, split per person
claude Nov 5, 2026
7a0962d
Holding news: skip days, a window back to the last check, no repeated…
claude Nov 5, 2026
86954d0
Rename ADMIN_CHAT_IDS to TELEGRAM_ADMIN_CHAT_IDS
claude Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ MCP data tools and web search. User-facing docs are in `README.md` (quick start)
- `store.py`: SQLite `Store`: history (`messages` shared by groups, `dm_messages` per bot), users, kv,
holding-news state, `usage` ledger, `text_cache` (finished /narrative and /events messages, 24 h), `period_cache` (finished Sharesight periods, kept until flushed). Thread-safe; call through `asyncio.to_thread`.
- `msgtext.py` sender_name/describe. `history.py` transcript lines. `textfmt.py` HTML helpers
(md_to_html, split_html, plain_text, TG_TAG_RE). `tickers.py` Yahoo links (+ `tickers_data.py`).
(md_to_html, split_html, plain_text, TG_TAG_RE). `tickers.py` quote links (`quote_links.py`: Yahoo, Google or TradingView, FINANCE_LINK_TARGET) on marked tickers (`$X`, `X.AX`, `ASX:X`, `Name (X)`, `X (ASX)`) and the known set `tickers.KNOWN`; bare words otherwise never link (+ `tickers_data.py`: exchange prefixes -> Yahoo suffixes, the gate's NOT_TICKERS). `holdings.py` current Sharesight holdings (holding news, known tickers).
- `mcp/`: `server.py` (MCPServer lifecycle, TTL cache, `Registry`), `schema.py` (neutral `ToolDef`,
schema diet), `results.py` (result slimming).
- `llm/`: `base.py` (Request/Step/Answer/Backend), `chat.py` (shared chat-completions loop), `xai.py` +
`openrouter.py` (the only provider-specific code), `runner.py` (THE tool loop), `policy.py` (THE retry ladder), `gate.py` (which tools a message needs).
- `prompts.py` all prompts. `ask.py` one entry point: route -> Request -> policy.ask -> usage -> NEEDS_TOOLS rerun.
- `telegram/`: `handlers.py` (trigger -> request -> reply), `send.py`, `draft.py` (typing + streaming
drafts), `commands.py` (/tokens), `access.py` (who is an admin). `features/`: `holding_news.py`, `movers.py`, `dm_buttons.py`, `chat_commands.py` (`chat_commands` in mcp_servers.json: per-server slash commands that force one tool; handler `Handlers.chat_command`, loaded onto `Ctx.chat_commands` in `app.build_ctx`), `filings.py` (/narrative and /events: TickerScout `get_narrative` / `get_events` one section at a time, one message each, edited by the buttons; /narrative always goes through the model (it calls the tool itself), /events only when a section is too long for a message; handlers in `commands.py`), `post.py`, `periods/` (local `period_returns` tool, cache-first; `windows.py`, `rows.py`, `backfill.py` = PERIOD_BACKFILL loop), `charts/` (local `make_chart` tool, offered when a message asks for a chart; series from the model or Yahoo price history; `render.py` = the facade (Chart, Series, themes, `scene()`, `png()`), laid out by `cartesian.py` / `pie.py` into a `scene.py` Scene (plain draw ops in pixels, which the tests read), ticks in `ticks.py`, painted by `raster.py` with Pillow at 2x (no numpy or matplotlib); the handler sends one reply, the photo with the answer as caption; `buttons.py` = 1D/1M/1Y/5Y/reload under a ticker chart, redrawn without the model from the `charts` table; `pages.py` = several charts in one reply as one image with a button each; `breakdown.py` = fast path: a plain "TICKER revenue breakdown" is drawn from TickerScout `get_segments` without the model).
drafts), `commands.py` (/usage), `access.py` (who is an admin). `features/`: `holding_news/` (`__init__.py` the daily check and its schedule, `text.py` bullets, codes, repeats, the window since the last check), `movers.py`, `known_tickers.py` (daily background refresh of `tickers.KNOWN`: S&P 500 + NASDAQ-100 + ASX 200 via Wikipedia's action API, Sharesight holdings; kept in kv), `dm_buttons.py`, `chat_commands.py` (`chat_commands` in mcp_servers.json: per-server slash commands that force one tool; handler `Handlers.chat_command`, loaded onto `Ctx.chat_commands` in `app.build_ctx`), `filings.py` (/narrative and /events: TickerScout `get_narrative` / `get_events` one section at a time, one message each, edited by the buttons; /narrative always goes through the model (it calls the tool itself), /events only when a section is too long for a message; handlers in `commands.py`), `post.py`, `periods/` (local `period_returns` tool, cache-first; `windows.py`, `rows.py`, `backfill.py` = PERIOD_BACKFILL loop), `charts/` (local `make_chart` tool, offered when a message asks for a chart; series from the model or Yahoo price history; `render.py` = the facade (Chart, Series, themes, `scene()`, `png()`), laid out by `cartesian.py` / `pie.py` into a `scene.py` Scene (plain draw ops in pixels, which the tests read), ticks in `ticks.py`, painted by `raster.py` with Pillow at 2x (no numpy or matplotlib); the handler sends one reply, the photo with the answer as caption; `buttons.py` = 1D/1M/1Y/5Y/reload under a ticker chart, redrawn without the model from the `charts` table; `pages.py` = several charts in one reply as one image with a button each; `breakdown.py` = fast path: a plain "TICKER revenue breakdown" is drawn from TickerScout `get_segments` without the model).
- `app.py` wiring/startup; `context.py` `Ctx` (settings, store, backend, registry, bot).
- `docs/plans/go-recode.md`: saved plan for a Go rewrite (not started); its key requirement is a modular design with self-contained features.
- `deploy/systemd/`: the example unit `grokbot.service` and the optional `grokbot.service.d/hardening.conf` drop-in (docs/running.md).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ without asking the model. Every key is listed in [How it works](docs/how-it-work
## More
- [Configuration](docs/configuration.md): providers, every environment variable, choosing a model.
- [How it works](docs/how-it-works.md): how it answers, search, MCP data tools (`mcp_servers.json`), formatting.
- [Features and commands](docs/features.md): charts, holding-news DM, movers replies, DM buttons, `/tokens`.
- [Features and commands](docs/features.md): charts, holding-news DM, movers replies, DM buttons, `/usage`.
- [Caching and token saving](docs/caching.md): the Sharesight history cache and the token-saving heuristics.
- [Running the bot](docs/running.md): systemd, several instances, logs.
- [Troubleshooting](docs/troubleshooting.md): startup errors, silent bots, MCP servers down.
Expand Down
2 changes: 1 addition & 1 deletion docs/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,5 @@ Each answer's log line shows the cached percentage.
symbol, tags and Yahoo URLs dropped), lines cut to 240 characters and the bot's own to 160.
- **Smaller prompts:** the system prompt is about half its previous size, the repeated instruction
tail lives in the (cached) system prompt, images go at low detail.
- **Measure it:** every request is recorded in the `usage` table (`/tokens`), and
- **Measure it:** every request is recorded in the `usage` table (`/usage`), and
`make prompt-report` prints where a typical request's tokens go.
74 changes: 30 additions & 44 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,51 +5,37 @@
### Essential
Set `TELEGRAM_BOT_TOKEN` and exactly one of the three LLM keys. With more than one, or none, the bot refuses to start.

| Variable | Meaning |
|---|---|
| `OPENROUTER_API_KEY` | OpenRouter key; selects the OpenRouter provider (default model `z-ai/glm-5.3-flash`). |
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather (required). |
| `XAI_API_KEY` | xAI key; selects the xAI provider (default model `grok-4.3`). |
| Variable | Default | Meaning |
|---|---|---|
| `OPENROUTER_API_KEY` | none | OpenRouter key; selects the OpenRouter provider (its default model is `deepseek/deepseek-v4.1-flash-1017`). |
| `TELEGRAM_BOT_TOKEN` | none (required) | Bot token from @BotFather (required). |
| `XAI_API_KEY` | none | xAI key; selects the xAI provider (its default model is `grok-4.3`). |

### Common settings
| Variable | Meaning |
|---|---|
| `BOT_TZ` | Time zone for timestamps, e.g. Australia/Melbourne (default UTC). |
| `MODEL` | Model ID (default grok-4.3 on xAI, z-ai/glm-5.3-flash on OpenRouter). |
| `REASONING` | Reasoning effort: low, medium or high; empty = the model's default. |
| `SEARCH` | on|off. Web search (and X search on xAI). Default on. |
| `DARKMODE` | Chart theme: `off` (the default) draws light charts, `on` draws dark ones, `auto` draws dark ones from 9 pm to 7 am in `BOT_TZ`. Before this setting existed, charts were always `auto`. |
| Variable | Default | Meaning |
|---|---|---|
| `TZ` | `UTC` | Time zone for timestamps, e.g. `Australia/Melbourne`. |
| `MODEL` | `grok-4.3` on xAI, `deepseek/deepseek-v4.1-flash-1017` on OpenRouter | Model ID. |
| `REASONING` | the model's own | Reasoning effort: `low`, `medium` or `high`. |
| `SEARCH` | `on` | `on` or `off`. Web search (and X search on xAI). |
| `DARKMODE` | `off` | Chart theme: `off` draws light charts, `on` draws dark ones, `auto` draws dark ones from 9 pm to 7 am in `TZ`. Before this setting existed, charts were always `auto`. |

### Advanced and optional
| Variable | Meaning |
|---|---|
| `ADMIN_CHAT_IDS` | Telegram IDs of the bot's admins (comma/space separated). Your user ID gets a message when an MCP server goes down and may use `/tokens`; a group's chat ID gets the down-server messages only. Admins of the groups the bot is in count as admins too. |
| `ADMIN_ONLY` | on|off. Only admins may DM the bot or get Sharesight data (default on). |
| `DATA_DIR` | Directory for the bot's data (the SQLite database `chat_log.db`). Bots started with the same `DATA_DIR` share group history. Default: systemd's `STATE_DIRECTORY` (see [Running under systemd](running.md#running-under-systemd)), else the working directory. In Docker, set `DATA_DIR=/data` and bind-mount a host directory there so history survives upgrades. |
| `HISTORY_LIMIT` | Messages of chat history in the prompt; the window is HISTORY_LIMIT to 1.5x (default 20). |
| `LOG_WIDTH` | Longest line (characters) for the MCP tool lists in the log; a longer list is cut at a whole name and ends in `…` (default 80, 0 = no limit). |
| `MAX_TOKENS` | Reply cap in tokens, reasoning included (default 3000). |
| `MOVERS_BOTS` | Usernames of bots whose end-of-day big-movers lists get explained. Empty (default) = feature off. |
| `PORTFOLIO_NAMES` | Comma list of Sharesight portfolio names; a message naming one is treated as a portfolio question (default: the recipients' portfolio names). |
| `PERIOD_BACKFILL` | Comma list of Sharesight portfolio names (or `all`) whose returns per day, week, month, quarter and year are fetched into the local cache in the background (about 1,650 Sharesight calls per portfolio the first time, a few hours), so "best day since inception" answers instantly. Default `all`; list names to limit it, or `off` to disable. See [Caching Sharesight history](caching.md#caching-sharesight-history). |
| `POST_TO_GROUPS_FROM_DM` | on|off. A group admin can have the bot post in that group from a DM (default off). |
| `SEARCH_MODEL` | OpenRouter only: model that runs the searches (default xiaomi/mimo-v2.6-flash:online). |
| `SHARESIGHT_HOLDING_NEWS_RECIPIENTS` | Comma list of portfolio:telegram_username pairs to notify. Empty (default) = daily holding-news DM off. |
| `SHARESIGHT_HOLDING_NEWS_TIME` | HH:MM (BOT_TZ) for the daily holding-news check (default 08:00). |
| `TELEGRAM_DM_BUTTONS` | on|off. Preset-prompt buttons in private chats (default off). |
| `TELEGRAM_PRELOAD_CHAT` | Chat ID of a private chat the bot can post in (a private group it is in, or a DM with you). The other periods of a chart are uploaded there in the background and reused when a period button is tapped, so the tap swaps the picture without fetching or drawing. Empty (default) = off, and a tap fetches and draws. See [Charts](features.md#charts). |

## Choosing a model
Figures from search results and Artificial Analysis at the time of writing (October 2026), so
treat them as rough; speed varies by provider.

| Model | $ per 1M in / out | Output speed | Quality index |
|---|---|---|---|
| `xiaomi/mimo-v2.6-pro` | 0.43 / 0.87 | ~28-46 tok/s | 46 (top open-weight model) |
| `xiaomi/mimo-v2.6-flash` | 0.14 / 0.28 | ~56 tok/s | 38 |
| `z-ai/glm-5.3-flash` (default on OpenRouter) | 0.15 / 0.50 | ~50 tok/s (other hosts up to ~270) | 57 |
| `grok-4.3` / `x-ai/grok-4.3` (default on xAI) | 1.25 / 2.50 | ~105-146 tok/s | 25 (at high reasoning) |
| `xiaomi/mimo-v2.5-pro` | 0.30 / 0.61 | ~29-46 tok/s | unreliable (retires 21 Oct 2026) |
| `xiaomi/mimo-v2.5` | 0.12 / 0.24 | ~44-58 tok/s | not found (retires 21 Oct 2026) |

Most of the delay is reasoning time before the first answer token, not typing speed: use `REASONING=low`.
| Variable | Default | Meaning |
|---|---|---|
| `ADMIN_ONLY` | `on` | `on`: only admins can DM the bot or get Sharesight (portfolio) data; anyone else asking about a portfolio is told it is for admins only. `off`: everyone can do both. Either way anyone can @mention the bot or reply to it in a group, and `/usage` and `/flushcache` stay admin-only. Who counts as an admin: `TELEGRAM_ADMIN_CHAT_IDS` plus anyone who administers a group the bot is in (see [Features](features.md#commands)). |
| `DATA_DIR` | systemd's `STATE_DIRECTORY`, else the working directory | Directory for the bot's data (the SQLite database `chat_log.db`). Bots started with the same `DATA_DIR` share group history. With systemd the default is its `STATE_DIRECTORY` (see [Running under systemd](running.md#running-under-systemd)). In Docker, set `DATA_DIR=/data` and bind-mount a host directory there so history survives upgrades. |
| `FINANCE_LINK_TARGET` | `yahoo` | Where ticker links go: `yahoo` (Yahoo Finance), `google` (Google Finance, `CODE:EXCHANGE`: `NXT.AX` opens `NXT:ASX`, `JPM` opens `JPM:NYSE`) or `tradingview` (`EXCHANGE-CODE`: `ASX-NXT`, `NYSE-BRK.A`). A US ticker's exchange comes from the text (`NYSE:JPM`, `JPM (NYSE)`), the holdings, the NASDAQ-100 or the S&P 500 list; one the bot can't place opens a Google Finance search, or TradingView's page for the bare code. |
| `HISTORY_MAX` | `10` | The most messages of chat history in the prompt. Once a chat is longer, the window holds half to all of that and its start only moves every half of it, so the prompt cache keeps working. |
| `MAX_TOKENS` | `3000` | Reply cap in tokens, reasoning included. |
| `MOVERS_BOTS` | empty (feature off) | Usernames of bots whose end-of-day big-movers lists get explained. |
| `PORTFOLIO_NAMES` | the holding-news recipients' portfolio names | Comma list of Sharesight portfolio names; a message naming one is treated as a portfolio question (default: the recipients' portfolio names). |
| `PERIOD_BACKFILL` | `all` | Comma list of Sharesight portfolio names (or `all`) whose returns per day, week, month, quarter and year are fetched into the local cache in the background (about 1,650 Sharesight calls per portfolio the first time, a few hours), so "best day since inception" answers instantly. List names to limit it, or `off` to disable. See [Caching Sharesight history](caching.md#caching-sharesight-history). |
| `POST_TO_GROUPS_FROM_DM` | `off` | `on` or `off`. A group admin can have the bot post in that group from a DM. |
| `SEARCH_MODEL` | `MODEL` with `:online` added | OpenRouter only: model that runs the searches (`:online` is what gives an OpenRouter model web search). Set but empty = no search model, so search is off. |
| `SHARESIGHT_HOLDING_NEWS_RECIPIENTS` | empty (daily DM off) | Comma list of portfolio:telegram_username pairs to notify. |
| `SHARESIGHT_HOLDING_NEWS_TIME` | `08:00` | HH:MM (in `TZ`) for the daily holding-news check. |
| `SHARESIGHT_HOLDING_NEWS_SKIP_DAYS` | empty | Weekdays (in `TZ`) with no holding-news check, e.g. `sun,mon` (names or their first three letters). The next check covers the gap: it always searches back to the last check (at most 7 days). Empty = every day. |
| `TELEGRAM_ADMIN_CHAT_IDS` | empty | Telegram IDs of the bot's admins (comma/space separated). Your user ID gets a message when an MCP server goes down and may use `/usage`; a group's chat ID gets the down-server messages only. Admins of the groups the bot is in count as admins too. |
| `TELEGRAM_DM_BUTTONS` | `off` | `on` or `off`. Preset-prompt buttons in private chats. |
| `TELEGRAM_IMAGE_PRELOAD_CHATID` | empty (off) | Chat ID of a private chat the bot can post in (a private group it is in, or a DM with you). The other periods of a chart are uploaded there in the background and reused when a period button is tapped, so the tap swaps the picture without fetching or drawing. Empty = off, and a tap fetches and draws. See [Charts](features.md#charts). |
Loading
Loading