Skip to content
Open
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
281c13f
SQLite: synchronous=NORMAL under WAL; period_cache WITHOUT ROWID
claude Oct 12, 2026
d2a8d71
/holdingnews says when the provider is busy; Sharesight MCP from robd…
claude Oct 13, 2026
d886028
Route market-session questions to tools and strip tool remarks from r…
claude Oct 15, 2026
95875cf
Make trading-session questions fetch current prices and news
claude Oct 15, 2026
573ff61
Force a search for trading-session questions
claude Oct 15, 2026
7b22d53
Keep market data tools beside the forced search for session questions
claude Oct 15, 2026
54d6f4c
Revert "Keep market data tools beside the forced search for session q…
claude Oct 15, 2026
34e7f0e
Session questions: ask for current news only, not prices
claude Oct 15, 2026
dd26bf8
Don't strip tool remarks from replies, so they stay visible
claude Oct 15, 2026
9b01102
Rename session prompt test
claude Oct 15, 2026
8e88008
Don't tell the model to avoid remarks about tools
claude Oct 15, 2026
17e65e5
Merge remote-tracking branch 'origin/main' into claude/compassionate-…
claude Oct 15, 2026
4c49c4d
Merge remote-tracking branch 'origin/main' into claude/compassionate-…
claude Oct 17, 2026
08f8695
xAI: keep only the last text part of a message, and log multi-part me…
claude Oct 17, 2026
b6fffa0
Revert session changes (built on stale feedback)
claude Oct 17, 2026
124ba2c
Merge remote-tracking branch 'origin/claude/next' into claude/compass…
claude Oct 17, 2026
12fe926
xAI: log a message that has several text parts
claude Oct 17, 2026
f8dffdb
Merge remote-tracking branch 'origin/main' into claude/compassionate-…
claude Oct 18, 2026
85db1b3
Open a PR right after pushing a claude/* branch, so the PR/CI button …
claude Oct 18, 2026
17c9d79
Drop the MCP library's 'Unknown SSE event: ping' keepalive warning
claude Oct 19, 2026
8ba9728
xAI: drop text written before a search call; log searches in the summ…
claude Oct 19, 2026
af30703
Log MCP tool lists on one line, cut at LOG_WIDTH (default 95)
claude Oct 19, 2026
78d69b8
Movers explanations: leave the earlier chat out of the prompt
claude Oct 20, 2026
02c3727
Fix the movers prompt test: give the two messages different ids
claude Oct 20, 2026
22995d5
Movers explanations: send the list once, with no transcript
claude Oct 20, 2026
70a7195
#nocontext: send the model only the message that tagged the bot
claude Oct 21, 2026
943d91c
Signal: answer DMs and group mentions through a signal-cli daemon the…
claude Oct 21, 2026
15d2d63
Rename SIGNALCLI_PATH to SIGNALCLI_BIN
claude Oct 21, 2026
08f9a76
Signal: connect to a daemon you run yourself (SIGNALCLI_SOCKET)
claude Oct 21, 2026
af5333a
Signal: remove the code that ran signal-cli; SIGNALCLI_SOCKET is the …
claude Oct 21, 2026
7d244c9
Remove Signal: restore the tree to 70a7195
claude Oct 22, 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: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ MCP data tools and web search. User-facing docs are in `README.md`; this file is
- `make check`: ruff + pytest (no network, a few seconds). Run it before every commit, on Python 3.13.5 (production;
`uv venv --python 3.13.5`). GitHub CI runs the latest stable Python and Node.js instead, to catch breakage from the next upgrade early.
- `make prompt-report`: token breakdown of a sample request. `python bot.py [label]` runs the bot.
- Pull requests: right after pushing a `claude/*` branch, open its PR if it has none, so Claude Code shows the PR/CI
button. Never merge it unless asked.
- Tests use fakes (`tests/conftest.py`: FakeBot, user_msg; `tests/fakes.py`: ScriptedBackend,
FakeMcp, fake SDK clients; `tests/fixtures/` real anonymised MCP output). Add a test next to the module you change;
don't write scratch scripts.
Expand All @@ -31,7 +33,7 @@ MCP data tools and web search. User-facing docs are in `README.md`; this file is

## Request flow
`handlers.on_message` logs the message -> `_trigger` (mention / reply-to-bot / DM / movers list) ->
`_generate` builds the prompt (`history.format_rows` + `prompts.chat_prompt`) -> `gate.route` picks tools ->
`_generate` builds the prompt (`history.format_rows` + `prompts.chat_prompt`; `#nocontext` in the message keeps only that message) -> `gate.route` picks tools ->
`ask.ask` builds a `Request` -> `policy.ask` -> `runner.run` loops `backend.step` / `run_calls` ->
`format_answer` (md_to_html, link_tickers) -> `reply_chunks` (split_html, save each sent message).

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ Restart=on-failure
provider's prompt cache keeps working. The bot's own earlier replies appear as "You" (its latest reply in full, so follow-up questions about it
work; older ones shortened); a replied-to
message is quoted even if it is older than the window.
- **`#nocontext`:** put `#nocontext` anywhere in a message that tags the bot (or in a DM) and the model gets that
message alone: no chat history, and nothing quoted from the message it replies to (no photo from it either). The tag
itself is removed before the model sees it. Useful for a fresh question that earlier chat would only confuse
or make more expensive; the bot's own recent reply is not read either, so follow-ups need the full question.
- **Shared database:** group history lives in `messages`, shared by every bot pointed at the same
`DB_PATH` (so two bots in one group see each other's replies). Private chats live in
`dm_messages`, keyed by bot, because each bot's DM with a person has its own message-ID sequence.
Expand Down Expand Up @@ -286,6 +290,7 @@ Set `TELEGRAM_BOT_TOKEN` and exactly one of the three LLM keys.
| `DATA_DIR` | Directory for the bot's data (the database); a relative `DB_PATH` resolves inside it, an absolute one is used as given. Default: systemd's `STATE_DIRECTORY` (see [Running under systemd](#running-under-systemd)), else the working directory. In Docker, set `DATA_DIR=/data` and bind-mount a host directory there so history survives upgrades. |
| `DB_PATH` | SQLite file for chat history (default chat_log.db). Instances may share it. |
| `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 95, 0 = no limit). |
| `MAX_TOKENS` | Reply cap in tokens, reasoning included (default 3000). |
| `MCP_CONFIG` | MCP server config file (default mcp_servers.json next to bot.py, whatever `DATA_DIR` is; missing = no MCP tools). |
| `MOVERS_BOTS` | Usernames of bots whose end-of-day big-movers lists get explained. Empty (default) = feature off. |
Expand Down
11 changes: 7 additions & 4 deletions lib/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,11 @@ def setup_logging() -> None:


def _drop_protocol_line(record: logging.LogRecord) -> bool:
"""The MCP library's own "Negotiated protocol version" line doesn't say which server it is about; the
bot logs "MCP <name>: connected, protocol ..." instead."""
return not record.getMessage().startswith("Negotiated protocol version")
"""Library log lines that are noise here: the MCP library's own "Negotiated protocol version" line
doesn't say which server it is about (the bot logs "MCP <name>: connected, protocol ..." instead), and
a remote server's 30 s keepalive arrives as an "Unknown SSE event: ping" warning."""
msg = record.getMessage()
return not (msg.startswith("Negotiated protocol version") or msg == "Unknown SSE event: ping")


async def alert_down(ctx: Ctx, server: MCPServer) -> None:
Expand Down Expand Up @@ -94,7 +96,8 @@ async def on_down(server: MCPServer) -> None:
await alert_down(ctx, server)

ctx.registry = Registry.load(st.mcp_config, timeout=config.MCP_TIMEOUT,
max_output=st.max_tool_output, on_down=on_down)
max_output=st.max_tool_output, on_down=on_down,
log_width=st.log_width)
return ctx


Expand Down
3 changes: 3 additions & 0 deletions lib/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"DATA_DIR": "Directory for the bot's data (the database); a relative DB_PATH resolves inside it. Default: systemd's STATE_DIRECTORY, else the working directory.",
"DB_PATH": "SQLite file for chat history (default chat_log.db). Instances may share it.",
"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; longer lists end in \"...\" (default 95, 0 = no limit).",
"MAX_TOKENS": "Reply cap in tokens, reasoning included (default 3000).",
"MCP_CONFIG": "MCP server config file (default mcp_servers.json next to bot.py; missing = no MCP tools).",
"MOVERS_BOTS": "Usernames of bots whose end-of-day big-movers lists get explained. Empty (default) = feature off.",
Expand Down Expand Up @@ -75,6 +76,7 @@ class Settings:
search_model: str
max_tokens: int
history_limit: int
log_width: int
db_path: str
tz: ZoneInfo
mcp_config: str
Expand Down Expand Up @@ -191,6 +193,7 @@ def load(env: Mapping[str, str] | None = None) -> Settings:
search_model=search_model,
max_tokens=int(env.get("MAX_TOKENS") or 3000),
history_limit=int(env.get("HISTORY_LIMIT") or 20),
log_width=int(env.get("LOG_WIDTH") or 95),
db_path=data_path(env, "DB_PATH", "chat_log.db"),
tz=ZoneInfo(env.get("BOT_TZ", "UTC")),
mcp_config=mcp_config_path(env),
Expand Down
4 changes: 2 additions & 2 deletions lib/llm/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,8 @@ async def run(backend: Backend, registry: Registry, req: Request, *, dedupe: boo
break
if req.must_search and not (tool_calls or searched):
log.warning("This request needed a search, but the answer shows no sign of one")
log.info("%.1fs, %d rounds, %d tools: in %d (%.0f%% cached) out %d, $%.4f",
time.monotonic() - t0, rounds, tool_calls, total.tokens_in, total.cached_pct,
log.info("%.1fs, %d rounds, %d tools, %d searches: in %d (%.0f%% cached) out %d, $%.4f",
time.monotonic() - t0, rounds, tool_calls, searched, total.tokens_in, total.cached_pct,
total.tokens_out, total.cost)
text = TOOL_SYNTAX_RE.sub("", step.text).strip()
if not text and step.finish == "length" and tool_calls:
Expand Down
38 changes: 25 additions & 13 deletions lib/llm/xai.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,26 @@ def _as_input(item) -> dict:
return item.model_dump(exclude_none=True) if hasattr(item, "model_dump") else dict(item)


def answer_text(resp) -> str:
"""The response's last message. `output_text` joins every message, and with server-side search
the model can write a draft, search, then write the answer, all in one response."""
def answer_text(resp, tail: str | None = None) -> str:
"""The response's last message, and in it the last text part. `output_text` joins everything, and
with server-side search the model can write a draft, search, then write the answer, as separate
messages, as parts of one, or as one run of text with the searches in between. `tail` is the text
streamed after the last search call; when the chosen text ends with it, the draft before it goes."""
messages = [i for i in resp.output if i.type == "message"]
parts = [[getattr(c, "text", "") or "" for c in m.content if c.type == "output_text"] for m in messages]
texts = ["".join(p) for p in parts]
for p in parts:
if len([t for t in p if t.strip()]) > 1: # not handled yet: this line tells us if it happens
log.info("xAI message had %d text parts (%s chars)", len(p), "+".join(str(len(t)) for t in p))
if len([t for t in texts if t.strip()]) > 1:
log.info("xAI response had %d messages; kept the last (items: %s)", len(texts),
" ".join(i.type for i in resp.output))
return next((t for t in reversed(texts) if t.strip()), resp.output_text or "").strip()
for p in parts:
if len([t for t in p if t.strip()]) > 1: # not handled yet: this line tells us if it happens
log.info("xAI message had %d text parts (%s chars)", len(p), "+".join(str(len(t)) for t in p))
text = next((t for t in reversed(texts) if t.strip()), resp.output_text or "").strip()
if tail and tail.strip() and len(tail.strip()) < len(text) and text.endswith(tail.strip()):
log.info("xAI wrote %d chars before a search call; kept the %d after it (items: %s)",
len(text) - len(tail.strip()), len(tail.strip()), " ".join(i.type for i in resp.output))
return tail.strip()
return text


class XaiBackend(Backend):
Expand Down Expand Up @@ -87,12 +94,17 @@ def _kwargs(self, req: Request, tool_choice: str | None) -> dict:

async def _create(self, req: Request, kw: dict, **inputs):
stream = await self.client.responses.create(**kw, **inputs)
text, final = "", None
text, final, tail = "", None, None
async for event in stream:
if event.type == "response.output_item.added" and event.item.type == "message":
text = "" # a new message replaces the draft before it
elif event.type == "response.output_item.added" and event.item.type.endswith("_search_call"):
if text.strip(): # text written before a search is a draft; only what follows counts
text, tail = "", ""
elif event.type == "response.output_text.delta":
text += event.delta
if tail is not None:
tail = text
if req.on_text:
req.on_text(text)
elif event.type in ("response.completed", "response.incomplete", "response.failed"):
Expand All @@ -102,23 +114,23 @@ async def _create(self, req: Request, kw: dict, **inputs):
if final.status == "failed":
msg = f"xAI response failed: {final.error}"
raise (Overloaded if getattr(final.error, "code", None) == "server_error" else RuntimeError)(msg)
return final
return final, tail

async def step(self, conv: dict, req: Request, *, tool_choice: str | None) -> Step:
kw = self._kwargs(req, tool_choice)
prev = conv["resp"]
try:
if self.stateless or prev is None:
resp = await self._create(req, kw, input=conv["input"])
resp, tail = await self._create(req, kw, input=conv["input"])
else:
resp = await self._create(req, kw, previous_response_id=prev.id, input=conv["pending"])
resp, tail = await self._create(req, kw, previous_response_id=prev.id, input=conv["pending"])
except BadRequestError as e:
if prev is None or not self.stateless:
raise
log.warning("xAI rejected the replayed conversation (%s); using previous_response_id "
"from now on", e)
self.stateless = False
resp = await self._create(req, kw, previous_response_id=prev.id, input=conv["last_results"])
resp, tail = await self._create(req, kw, previous_response_id=prev.id, input=conv["last_results"])
conv["resp"] = resp
calls = [Call(i.call_id, i.name, i.arguments) for i in resp.output if i.type == "function_call"]
u = resp.usage
Expand All @@ -130,7 +142,7 @@ async def step(self, conv: dict, req: Request, *, tool_choice: str | None) -> St
cost=(getattr(u, "cost_in_usd_ticks", 0) or 0) / TICKS_PER_USD,
)
searches = sum(1 for i in resp.output if i.type.endswith("_search_call"))
return Step(answer_text(resp), calls, usage, resp.status, searches)
return Step(answer_text(resp, tail), calls, usage, resp.status, searches)

def add_results(self, conv: dict, step: Step, results: list[str]) -> None:
items = [{"type": "function_call_output", "call_id": c.id, "output": out}
Expand Down
31 changes: 19 additions & 12 deletions lib/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import logging
import re
import tempfile
import textwrap
import time
from collections.abc import Awaitable, Callable
from contextlib import AsyncExitStack
Expand Down Expand Up @@ -44,11 +43,11 @@ class MCPServer:
entered and exited in the same task."""

def __init__(self, label: str, cfg: dict, *, timeout: float, max_output: int,
on_down: Callable[[MCPServer], Awaitable[None]] | None = None):
on_down: Callable[[MCPServer], Awaitable[None]] | None = None, log_width: int = 95):
self.label = re.sub(r"[^A-Za-z0-9_-]", "_", label)
self.cfg = cfg
self.description = cfg.get("description", "")
self.timeout, self.max_output, self.on_down = timeout, max_output, on_down
self.timeout, self.max_output, self.on_down, self.log_width = timeout, max_output, on_down, log_width
self.session: ClientSession | None = None
self.error: str | None = None # why the server is down, if it is
self.tools: list[ToolDef] = []
Expand Down Expand Up @@ -148,9 +147,9 @@ async def _load_tools(self, session: ClientSession) -> None:
self._warn_unknown_names(listed, allow | blocked | set(self._warm))
enabled = sorted(self.fn_names.values())
skipped = sorted(t.name for t in listed if t.name not in enabled)
log_names(f"MCP {self.label}: {len(enabled)} tools:", enabled)
log_names(f"MCP {self.label}: {len(enabled)} tools:", enabled, self.log_width)
if skipped:
log_names(f"MCP {self.label}: {len(skipped)} skipped:", skipped)
log_names(f"MCP {self.label}: {len(skipped)} skipped:", skipped, self.log_width)

def _warn_unknown_names(self, listed, configured: set[str]) -> None:
"""A blocked/allowed name that matches no tool does nothing; say so (and what was meant)."""
Expand Down Expand Up @@ -290,7 +289,7 @@ def __init__(self, servers: list[MCPServer]):

@classmethod
def load(cls, path: str, *, timeout: float, max_output: int,
on_down: Callable[[MCPServer], Awaitable[None]] | None = None) -> Registry:
on_down: Callable[[MCPServer], Awaitable[None]] | None = None, log_width: int = 95) -> Registry:
"""Servers from a JSON file ({"mcpServers": {...}} or Claude Desktop style); a missing
file means no servers."""
try:
Expand All @@ -299,7 +298,8 @@ def load(cls, path: str, *, timeout: float, max_output: int,
except FileNotFoundError:
return cls([])
cfg = cfg.get("mcpServers", cfg)
return cls([MCPServer(label, c, timeout=timeout, max_output=max_output, on_down=on_down)
return cls([MCPServer(label, c, timeout=timeout, max_output=max_output, on_down=on_down,
log_width=log_width)
for label, c in cfg.items() if not c.get("disabled")])

def start(self) -> None:
Expand Down Expand Up @@ -330,11 +330,18 @@ def lookup(self, fn_name: str) -> tuple[MCPServer, str] | None:
return None


def log_names(head: str, names: list[str], width: int = 80) -> None:
"""Log a list of tool names on lines short enough not to wrap in a terminal or journal."""
lines = textwrap.wrap(", ".join(names), width - len(head))
for i, line in enumerate(lines):
log.info("%s %s", head if i == 0 else " " * len(head), line)
def log_names(head: str, names: list[str], width: int = 95) -> None:
"""Log a list of tool names on one line of at most `width` characters (0 = no limit). A list that
doesn't fit is cut at a whole name and ends in "..."."""
line = f"{head} {', '.join(names)}"
if width and len(line) > width:
kept = f"{head} "
for i, name in enumerate(names):
if len(kept) + len(name) + len(", ...") > width and i:
break
kept += f"{name}, "
line = kept + "..."
log.info("%s", line)


def _leaf_errors(e: BaseException) -> list[BaseException]:
Expand Down
7 changes: 5 additions & 2 deletions lib/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,11 @@ def chat_prompt(*, transcript: str, sender: str, private: bool, reply_quote: str
fresh: bool = False) -> str:
"""The user turn: transcript, the replied-to message, and the volatile tail (kept last so the
cached prefix before it is unchanged)."""
head = (f"Private one-to-one chat with {sender}, not the group. Transcript:\n{transcript}"
if private else f"Group chat transcript:\n{transcript}")
if transcript:
head = (f"Private one-to-one chat with {sender}, not the group. Transcript:\n{transcript}"
if private else f"Group chat transcript:\n{transcript}")
else: # a standalone request (a movers list): what follows is all there is
head = f"Private one-to-one chat with {sender}, not the group." if private else "Group chat."
if reply_quote:
head += f"\n\nThe last message (#{msg_id}) is a reply to this message:\n{reply_quote}"
head += middle
Expand Down
Loading
Loading