Repository navigation
Conversation
… plain stand-ins Pillow's default font has no ² ³ − → € etc., so they drew as boxes. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
…ws the share) Falls back to shares when a value would round to zero. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
…hes value legend Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
…ks none The url_citation annotations were only counted; now they are kept on the Step and Answer and ask() appends a Sources line (max 3) to chat answers that carry no link of their own. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
Positions from the url_citation annotations are applied to the raw reply before any formatting; the Sources line stays as the fallback when no marker could be placed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
…CHAT; log user input as two words - LOG_WIDTH removed: tool-name lists are logged in full. - BOT_TZ -> TZ, HISTORY_LIMIT -> HISTORY_MAX (default 10, and a real maximum: the stepped window now holds max/2+1 .. max messages), TELEGRAM_PRELOAD_CHAT -> TELEGRAM_IMAGE_PRELOAD_CHATID. The old names are not read any more. - User input in the log is cut to its first two words and one ellipsis character; the other log-line truncations (error snippets, failed HTML) are gone. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
…Default column Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
…rs were placed Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
OpenRouter's url_citation positions are 0 and the search runs in an earlier round than the answer, so the position-based marker is replaced by matching the answer's sentences to each source's snippet (figures weigh most, words every snippet shares count for less). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
The chat system prompt (OpenRouter only) tells the model to cite a sentence that rests on a search result as [<a href=URL>n</a>]. Its links are kept only for pages the search returned and renumbered; without them the snippet-overlap markers apply. The trailing Sources line is gone. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
…ere dropped Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
…n order The model cited 3 of ~10 pages the search returned; the overlap pass now runs for the rest even when the model wrote markers of its own. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
Bare capitalised words (GAAP, EBITDA, AFR, GM, UPS) kept getting Yahoo links and the stoplist never ended; ASX:NXT linked to the US NXT. Replies now link only $NVDA, NXT.AX, ASX:NXT (mapped to Yahoo's suffix), Name (TICKER), Sharesight's NXT (ASX), ^GSPC and BTC-USD, plus bare words in a known set: the S&P 500 and NASDAQ-100 (from Wikipedia) and the US Sharesight holdings, refreshed daily in the background and kept in kv, never fetched while answering. A bare code in brackets links only if known, since it names no exchange. Holding news links the holdings' codes and a movers explanation the listed codes with the list's exchange. The prompt asks for these forms. The NOT_TICKERS env var is gone; the built-in list now only serves the tool gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
- An exchange suffix links on its own: ALL.AX (Aristocrat), ASX.AX, AI.PA were left plain because
the code is also on the acronym list.
- A bracket that isn't a ticker ("NVDA (Q3 FY26)") no longer swallows the ticker before it.
- Share classes: BRK.B as the model writes it links to Yahoo's BRK-B.
- "AT&T (T)" and "Procter & Gamble (PG)" are bolded whole.
- Numeric holding codes (700 on HKEX) never link bare numbers, and keep their unsubscribe button.
- Index tickers that are also finance or tech acronyms (GM, UPS, IR, HD, USB, RF...) link only in a
marked form, as single letters already did.
- A refresh part that fails (a page down, Sharesight not up yet) is tried again in 30 minutes,
not 24 hours; a footnote in the table header no longer hides the ticker column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
…the ASX 200 Wikipedia answered 403 to the page fetches: its policy refuses programs whose User-Agent names no contact. The lists now come through the action API (action=parse) with the repo URL in the User-Agent, and a refusal logs Wikimedia's reason. The S&P/ASX 200 is added: its codes link bare with .AX. Holdings on any known exchange count (NXT -> NXT.AX). A code in a US index and in the ASX 200 isn't linked bare unless a holding settles it. ASX codes that are everyday words or acronyms (GPT, WOW, ZIP, CAR...) need a marked form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
The "Nasdaq-100" article has no constituents table any more (0 tickers in production). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
…gle Finance Google takes CODE:EXCHANGE (NXT.AX -> NXT:ASX, 0700.HK -> 700:HKG); a US ticker names no exchange (NASDAQ or NYSE), so it opens a Google Finance search. History compaction and the holding-news mute codes read either provider's links back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
Google Finance quotes are CODE:EXCHANGE (JPM:NYSE, SPCX:NASDAQ, BRK.A:NYSE). A US ticker's exchange now comes from the text (NYSE:JPM, JPM (NYSE)), the Sharesight holdings, the NASDAQ-100 (all NASDAQ) or the S&P 500 list, whose ticker links point at nyse.com or nasdaq.com; kept in kv with the lists. Only a ticker none of these place opens a Google Finance search. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
TradingView pages are EXCHANGE-CODE (ASX-NXT, NYSE-BRK.A, NASDAQ-SPCX), with the US exchange from the same sources as Google's; a code it can't place opens TradingView's page for the bare code. The three sites' URL builders leave tickers.py (near its size limit) for their own module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
Shows what the daily holding-news check (and movers, DM buttons, chat commands, filings) costs next to chat; every request was already recorded with its kind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
/tokens and /credits still work. Telegram shows the menu commands in autocomplete, so admins now see /usage there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
Each recipient used to get a separate searched request, so a stock two people hold was searched twice and every request paid its own overhead (holding news was the biggest cost in /usage). The daily check now asks once for the union of everyone's unmuted holdings, with everyone's recent items as the don't-repeat list, then gives each person the items about their own holdings; an item naming no holding goes to everyone. /holdingnews still checks just the asker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
… articles SHARESIGHT_HOLDING_NEWS_SKIP_DAYS (e.g. sun,mon) skips the daily check on those weekdays. Each check now searches back to the last one that got an answer (kv; at least 24 h, at most 7 days), so skipped days, a busy provider or an outage leave no gap; the prompt and the message header say how far (past 72h). Repeats: the don't-repeat list always reaches past the last check, and an item citing an article already sent is dropped whatever the model says. The feature becomes a package (holding_news/ with text.py) to stay under the module size limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
The old name is no longer read: rename it in the service's environment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
…AMES The daily known-ticker refresh now reads every portfolio list_portfolios returns (skipping consolidated views, which repeat the others): their holdings feed the known tickers and their names route portfolio questions in the gate. Both are kept in kv, so a restart has them at once. The PORTFOLIO_NAMES setting is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
…arts Two charts from Yahoo's get_analyst_estimates (revenue, EPS), each with Low, Average and High lines fanning out from last fiscal year's actual through this and next fiscal year; the axis is labelled with real fiscal years from the latest annual statement. Drawn with the existing make_chart, no model. History uses the estimates' own year-ago figures, so EPS stays on the analysts' (adjusted) basis rather than mixing in GAAP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
…earch The combined daily check searches every holding in one request, and OpenRouter's search tool capped the whole request at 20 results, so it ran out part-way (and said so in a "Note:" bullet sent to everyone). Requests can now ask for a larger allowance (Request.search_results; OpenRouter's max_total_results) and the daily check asks for 60. ETFs and funds, found by name, are left out of the search. The prompt asks for news items only, and lines about the search are dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
The Yahoo server is now yahoo-finance2's own (npx -y -p yahoo-finance2 yahoo-finance-mcp): generic tools (chart, quote, quoteSummary, fundamentalsTimeSeries, search, ...) returning raw yahoo-finance2 JSON. The old tool names are gone, so what called them by name moves over: - Ticker charts and their period buttons fetch prices with `chart` (period1 from the period; times in the exchange's own time zone; 1D and 5D keep the last trading days, so a weekend isn't empty). The fetch passes `_raw`, a new internal flag (no slimming, no size cap, stripped from the model's calls like `_keep_sales`), so a day of 5-minute bars isn't thinned to 60 points. - /future reads quoteSummary's earningsTrend; the fiscal-year labels come from its end dates (a June year is right for ASX names), so the statements call is gone. - Quotes are trimmed to the fields a model uses (about 870 characters each instead of 3,700), so a ten-stock quote fits one result instead of being cut after three. - mcp_servers.json: the new command, an allowed_tools list (quote, quoteSummary, chart, fundamentalsTimeSeries, search: names that don't look read-only, and the rest left out), moduleOptions hidden, and a description naming the queryOptions the model needs. The Yahoo part of the prompt drops from about 1,120 to 725 tokens. - Fixtures are real output from the new server; tools/capture.py and the docs follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
A wrong URL, a refused, failed or unauthorised connection, a missing command or an MCP error is logged as "MCP server yahoo failed: HTTP 401 Unauthorized from http://127.0.0.1:3000/mcp", without the SDK's nested ExceptionGroup traceback (httpx's "For more information" line and a local server's stderr are folded into the same line). Anything else still gets its traceback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
Besides off, on and auto (9 pm to 7 am), DARKMODE can now be a range in TZ, in 24-hour or am/pm form, with or without minutes; it may run past midnight and ends just before its last time. Settings.darkmode is now that range as minutes of the day (None = off), and the chart checks the minute, not just the hour. A value that isn't a range (18, 25-6, 6-6) stops startup with the accepted forms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
The dark hours are now a range you set (21-7 is what auto was); auto is refused at startup with the accepted forms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
Telegram's 4096-character message limit (and 1024 for captions) counts the text after the HTML is parsed: a link counts only its label. split_html counted the raw HTML, so every ticker and [n] citation URL used up room, and a reply of ~2,500 visible characters with many citations was split in two. - textfmt.visible_len: tags count nothing, an entity counts as its character, emoji as two UTF-16 units; split_html cuts by it. - plain_text(text, limit): the plain-text fallback drops the URLs when "label (url)" would push a message or caption over Telegram's limit. - filings: the "fits in one message" check uses visible_len too, so /events isn't sent to the model to shorten when it already fits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
Found by review of the previous commit: - A cut that landed just after an <a href="...?a=1&b=2"> tag found the href's raw "&" and moved back inside the tag, leaving both messages with broken HTML. The cut now stops before tags that follow the last character that fits (they open the next message), an "&" moves the cut only when it starts a real entity that the cut would split, and the tag check runs last. - Telegram decodes only < > & " and numeric entities; any other "&name;" is shown as typed, so visible_len counts it in full. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
The known-tickers refresh tests fill it and leave it set, so a later test on the same xdist worker found NVDA placed on NASDAQ and the Google Finance link test failed about one run in ten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replies full of links (DeepSeek's cited answers especially) were split into two messages even when they were well under Telegram's limit.
Why
Telegram's 4096-character message limit (1024 for captions) counts the text after the HTML is parsed: a link counts only its label.
split_htmlcounted the raw HTML, so every ticker link and[n]citation URL (often 100–250 characters) used up room. A reply of ~2,500 visible characters with 15 citations and 15 ticker links went over 3,904 raw characters and was cut at the last newline.What
textfmt.visible_len: length as Telegram counts it. Tags count nothing;< > & "and numeric entities count as their character, while any other&name;(which Telegram shows as typed) counts in full; emoji count as two UTF-16 units.split_htmlnow cuts by it, keeping its newline, tag and entity rules. The default size isMAX_TG_MESSAGE(4000).split_htmlnever cuts inside a tag. A cut stops before tags that follow the last character that fits, so they open the next message. A raw&in an href's query string no longer counts as an entity to back away from.plain_text(text, limit): the plain-text fallback, used when Telegram rejects the HTML, drops the URLs when "label (url)" would push a message or caption over Telegram's limit. Used insend.py,commands.edit_in_placeand the chart caption swaps.filings.build: the "fits in one message" check usesvisible_len, so /events isn't sent to the model to shorten when it already fits.quote_links.US_EXCHANGES. The known-tickers tests leaked it, and the Google Finance link test failed about one run in ten under xdist.docs/how-it-works.md, CLAUDE.md map.The
-s oursmerge commit brings in the squash-merged #50 history (identical tree), so the branch could be pushed without a force-push.Tests
make check(Python 3.13.5): 567 passed; 25 repeated runs had no failures. New tests:&(sizes swept across the tag boundary);plain_textdrops URLs past the limit.Not checked against live Telegram: there's no access from the sandbox.
🤖 Generated with Claude Code
https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw