Skip to content

Commit 89bae1b

Browse files
Alexey Panfilovclaude
andcommitted
docs: update README and CLAUDE.md for Ollama, voice, web search, stability
- Add Ollama Cloud provider, voice messages, web search to features list - Add Ollama config example and web_search config to README - Update architecture diagram with Ollama, voice flow, web search - Document ollama.go native provider, websearch.go built-in tool - Update cache params (0.92 threshold, 4h TTL), classifier truncation - Document concurrency limiter, shutdown timeout, MCP Close() - Document config validation, reusable HTTP clients, tool result summarization - Update multimodal content types (input_audio now wired) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0cf17a0 commit 89bae1b

2 files changed

Lines changed: 45 additions & 17 deletions

File tree

CLAUDE.md

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ flowchart LR
4545

4646
**`internal/llm`** — LLM abstraction.
4747
- `provider.go``Provider` interface + `Message`, `Tool`, `ContentPart`, `ImageURL` types
48-
- `openai_compat.go` — shared OpenAI-compatible implementation using raw `net/http` (no go-openai). `buildMessages(messages, systemPrompt, vision bool)` serialises messages with full control: assistant messages with `tool_calls` and empty content use `"content": null` (not omitted) to satisfy all provider APIs. `image_url` parts are replaced with `[image]` for non-vision providers. Defines `APIError{StatusCode, Message}` for fallback routing.
49-
- `router.go` — thread-safe routing config (`mu` protects both `override` and `cfg`). Priority: multimodal → override → classifier → reasoner → primary → fallback on 5xx/429/network. `SetRole(role, model)` + `SetClassifierMinLen(n)` allow runtime changes; `saveOverrides()` persists to `persistPath` (JSON) on every change; `LoadPersistedOverrides()` applies saved values on startup. Classifier has a 5 s timeout.
48+
- `openai_compat.go` — shared OpenAI-compatible implementation using raw `net/http` (no go-openai). `buildMessages(messages, systemPrompt, vision bool)` serialises messages with full control: assistant messages with `tool_calls` and empty content use `"content": null` (not omitted) to satisfy all provider APIs. `image_url` parts are replaced with `[image]` for non-vision providers; `input_audio` parts likewise. Defines `APIError{StatusCode, Message}` for fallback routing.
49+
- `ollama.go` — native Ollama provider using `/api/chat` protocol (not OpenAI-compat). Supports Ollama Cloud (`https://ollama.com`) and local instances. Handles tool calling (Ollama returns `arguments` as object → serialised to JSON string; generates `call_N` IDs since Ollama omits them). Multimodal: base64 images sent via `images[]` field (strips data URI prefix). `stream: false` for synchronous responses.
50+
- `router.go` — thread-safe routing config (`mu` protects both `override` and `cfg`). Priority: multimodal → override → classifier → reasoner → primary → fallback on 5xx/429/network. `SetRole(role, model)` + `SetClassifierMinLen(n)` allow runtime changes; `saveOverrides()` persists to `persistPath` (JSON) on every change; `LoadPersistedOverrides()` applies saved values on startup. Classifier has 5 s timeout; input truncated to 500 chars; logs routing decisions at Info when routed to reasoner.
5051
- All providers are optional in `main.go` — any configured model can be `routing.default`. The bot exits with a clear error if the default provider is missing.
5152

5253
**`internal/store`** — conversation history.
@@ -65,43 +66,49 @@ flowchart LR
6566
- Security: `validateServerURL` blocks loopback/link-local addresses and non-http(s) schemes; tool names validated by regex + 128-char limit; descriptions truncated to 4 KB; args size capped at 1 MB; responses capped at 10 MB via `io.LimitReader`; tool results truncated to 100 KB
6667
- **Vector tool filtering**: `EnableEmbeddings(cfg, topK)` + `EmbedTools(ctx)` at startup; `LLMToolsForQuery(ctx, query)` returns top-K most relevant tools per request via cosine similarity
6768
- **`EmbedText(ctx, text) ([]float32, error)`** — public method; used by agent for message embedding (shared embedding layer with tool filtering)
69+
- **`Close()`** — releases HTTP connection pools for all MCP servers; called on shutdown
6870

6971
**`internal/mcp/embeddings.go`** — embedding provider abstraction.
7072
- `embed(ctx, cfg, text)` dispatches by `cfg.Provider`: `"hf-tei"` → HuggingFace TEI (`POST /embed`, Basic Auth); `"openai"` → OpenAI-compatible (`POST /v1/embeddings`, Bearer); default → Gemini (`embedContent` API, `x-goog-api-key`)
71-
- `doEmbedRequest` — shared HTTP helper with `io.LimitReader(10 MB)`
73+
- `doEmbedRequest` — shared HTTP helper with `io.LimitReader(10 MB)` and reusable `embedHTTPClient` (package-level singleton)
7274
- `cosineSimilarity` — used for tool filtering; `cosineSimilarityF32` (same logic) in `store/sqlite.go` for history
7375

7476
**`internal/agent`** — agentic loop.
7577
- `Process(ctx, chatID, llm.Message, onToolCall)`:
7678
1. Calls `storeUserMessage` — embeds user message via `mcp.EmbedText` and stores with embedding if `SemanticStore` available; falls back to plain `AddMessage`
77-
2. Auto-compacts if needed
79+
2. Auto-compacts if needed (with 2 min timeout)
7880
3. Calls `buildCrossSessionContext` — searches past sessions, formats snippets for system prompt (top 5, cosine > 0.75, 3000 char budget; 200/300 chars per user/bot snippet)
79-
4. Agentic loop: calls `getHistory``Router.Chat` → handles tool calls (max 5 iterations)
81+
4. Agentic loop: calls `getHistory``Router.Chat` → handles tool calls (max 5 iterations); tool results > 2000 chars are auto-summarised via LLM before storing in history
82+
- `TranscribeAudio(ctx, audioData, format)` — sends audio to multimodal provider with transcription prompt; returns text. Used by handler for voice messages.
83+
- `EnableWebSearch(cfg)` — activates built-in `web_search` tool; `callTool()` dispatches built-in tools before MCP.
8084
- `getHistory(chatID, queryEmb)` — uses `SemanticStore.GetSemanticHistory(chatID, emb, 10, 20)` when embedding available; falls back to `GetHistory`
8185
- `GetStats(chatID) (store.ChatStats, bool)` — type-asserts store to `CompactableStore`
82-
- `cache.go``ResponseCache`: in-memory LLM response cache keyed by query embedding. `Get(chatID, emb)` returns hit if cosine ≥ 0.97 and not expired. `Set(chatID, emb, response)` stores entry; evicts expired on write, then oldest if at capacity. TTL=1h, maxSize=200. Checked before LLM call; only pure direct responses (first iteration, no tool calls) are cached.
86+
- `cache.go``ResponseCache`: in-memory LLM response cache keyed by query embedding. `Get(chatID, emb)` returns hit if cosine ≥ 0.92 and not expired. `Set(chatID, emb, response)` stores entry; evicts expired on write, then oldest if at capacity. TTL=4h, maxSize=200. Checked before LLM call; only pure direct responses (first iteration, no tool calls) are cached.
87+
- `websearch.go` — Ollama web search built-in tool. `callWebSearch(ctx, cfg, argsJSON)` calls `/api/web_search` with Bearer auth. Returns formatted results (title, URL, content snippet). Registered as `web_search` tool visible to any LLM. Agent dispatches via `callTool()` which checks built-in tools before forwarding to MCP.
8388
- `compact.go` — token-based threshold: 16 000 estimated tokens. Fast pre-check at 32 000 chars. `EstimateTokens`: `len(Content)/4` + text parts `/4` + images `×1000`. **Semantic compaction**: `GetAllActive` now populates `MessageRow.Embedding`; if embeddings are present, `clusterByEmbedding` groups turns into topic clusters (greedy cosine, threshold 0.65) and each cluster is summarised separately — results joined with `---`. Falls back to single-pass when no embeddings or only one cluster.
8489

8590
**`internal/telegram`** — Telegram Bot API handler.
8691
- `markdown.go` — Markdown → Telegram HTML converter. No external deps.
8792
- `handler.go`:
88-
- 2 s debounce batch merges text, photos (max 5), forwarded messages into one `llm.Message`
93+
- 2 s debounce batch merges text, photos (max 5), voice messages, forwarded messages into one `llm.Message`
94+
- **Voice transcription**: `transcribeVoice()` downloads OGG from Telegram, calls `agent.TranscribeAudio()` (30 s timeout), result merged as text into the batch
8995
- **Race condition fix**: `version` counter on each batch; timer bails if version changed
9096
- **Reply chain**: `buildReplyQuote()` prepends `[Replying to: "..."]` (max 300 chars)
91-
- **Graceful shutdown**: `Drain()` atomically swaps batches map, flushes synchronously
97+
- **Graceful shutdown**: `Drain()` atomically swaps batches map, flushes synchronously (30 s timeout in main.go)
98+
- **Concurrency limiter**: semaphore limits `handleUpdate` goroutines to 10 concurrent
9299
- **Smart forward buffer**: forward-only batch → `bufferForwards()` embeds each entry via `agent.EmbedText` and stores `[]forwardEntry{text, emb}` with 5 min TTL. On follow-up question: question is embedded, `selectForwards()` scores buffered entries by cosine similarity and keeps those ≥ 0.25 (min 2 always included). Falls back to all entries when embeddings unavailable or ≤ 3 entries buffered.
93100
- `/routing` — inline keyboard for live routing changes; callback `rt:set:<role>:<model>` calls `agent.SetRoutingRole`; logs `routing change requested/applied`
94101
- `/stats` — calls `agent.GetStats`, formats stats message
95102
- `NotifyMissingRouting()` — startup check; sends inline model-picker for unconfigured routing roles
96103
- Responses ≥ 4096 chars sent as `response.md`
97-
- `downloadFile` uses 30 s timeout HTTP client
104+
- `downloadFile` uses reusable 30 s timeout HTTP client (`downloadHTTPClient` package-level singleton)
98105

99106
### Configuration files
100107

101108
| File | Purpose |
102109
|---|---|
103-
| `.env` | Secrets: `TELEGRAM_BOT_TOKEN`, `DEEPSEEK_API_KEY`, `GEMINI_API_KEY`, `QWEN_API_KEY`, `TELEGRAM_OWNER_CHAT_ID`, `TZ` (default `Europe/Belgrade`), `EMBED_API_KEY` (optional, HF-TEI) |
104-
| `config/config.yaml` | Models, routing, tool_filter `${ENV_VAR}` substitution. All models require `base_url`; `embedding` is exception (no `base_url`/`max_tokens`) |
110+
| `.env` | Secrets: `TELEGRAM_BOT_TOKEN`, `DEEPSEEK_API_KEY`, `GEMINI_API_KEY`, `QWEN_API_KEY`, `OLLAMA_API_KEY` (optional), `TELEGRAM_OWNER_CHAT_ID`, `TZ` (default `Europe/Belgrade`), `EMBED_API_KEY` (optional, HF-TEI) |
111+
| `config/config.yaml` | Models, routing, tool_filter, web_search `${ENV_VAR}` substitution. All models require `base_url`; `embedding` is exception (no `base_url`/`max_tokens`). `Validate()` checks required fields and numeric ranges on load. |
105112
| `config/routing.json` | Runtime routing overrides from `/routing` UI — auto-created, applied over `config.yaml` at startup. **Applied before compacter init**, so effective primary is always correct. |
106113
| `config/mcp.json` | MCP servers in Claude Desktop format |
107114
| `config/system_prompt.md` | System prompt injected on every LLM request |
@@ -171,7 +178,7 @@ GitHub Actions (`.github/workflows/ci.yml`) runs `go test -race -count=1 ./...`
171178

172179
### Adding multimodal content types
173180

174-
`llm.Message.Parts []ContentPart` supports `"text"`, `"image_url"`. Audio (`"input_audio"`) is defined in types but not wired in the handler.
181+
`llm.Message.Parts []ContentPart` supports `"text"`, `"image_url"`, `"input_audio"`. Audio is used for voice message transcription (Telegram OGG → Gemini `input_audio`). Non-vision providers replace `image_url` with `[image]` and `input_audio` with `[audio]` text placeholders.
175182

176183
### Companion MCP servers
177184

README.md

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ A lightweight Telegram bot that acts as a personal AI assistant. Written in Go
55
## Features
66

77
- **Multi-model routing** — any configured model can be primary; automatic fallback on errors or rate limits; dedicated reasoner for complex tasks; vision model for images; classifier-based routing to reasoner
8+
- **Ollama Cloud support** — native `/api/chat` provider for Ollama Cloud and local Ollama instances; tool calling, multimodal, Bearer auth
9+
- **Voice messages** — send a voice message in Telegram and it's automatically transcribed via the multimodal model (Gemini), then processed as text through the normal pipeline
10+
- **Web search** — built-in Ollama web search tool; any LLM model can search the web for real-time information
811
- **Semantic conversation memory** — user messages are embedded and stored; within a session, relevant past turns are retrieved by cosine similarity instead of just "last N messages"
912
- **Cross-session memory** — past conversations are searched across all sessions; relevant snippets are automatically injected into the system prompt so the bot remembers what you discussed weeks ago
1013
- **Image support** — send a photo (with or without caption) and it's routed automatically to the vision model
@@ -15,7 +18,7 @@ A lightweight Telegram bot that acts as a personal AI assistant. Written in Go
1518
- **Configurable embeddings** — shared embedding layer used for both tool filtering and conversation memory; supports Gemini (default), HuggingFace TEI, or any OpenAI-compatible endpoint
1619
- **Persistent memory** — SQLite-backed conversation history with automatic session management
1720
- **Token-based compaction** — auto-summarises old history when estimated token count exceeds threshold; images count as 1000 tokens each
18-
- **Embedding response cache**identical or near-identical queries (cosine ≥ 0.97) return a cached response without calling the LLM; per-chat, 1-hour TTL, 200-entry capacity
21+
- **Smart token usage**classifier input truncated to 500 chars; large tool results (>2 KB) auto-summarised before entering history; response cache with cosine ≥ 0.92 threshold and 4-hour TTL
1922
- **Rich formatting** — Markdown converted to Telegram HTML; responses ≥ 4096 chars sent as `response.md`
2023
- **Access control** — allowlist by chat ID + owner-only enforcement
2124
- **Date/time awareness** — current date and time injected into every request; timezone set via `TZ` env var
@@ -25,7 +28,7 @@ A lightweight Telegram bot that acts as a personal AI assistant. Written in Go
2528

2629
- Go 1.24+ (or Docker)
2730
- [Telegram Bot Token](https://t.me/BotFather)
28-
- At least one LLM API key (DeepSeek, Gemini, or Qwen)
31+
- At least one LLM API key (DeepSeek, Gemini, Qwen, or Ollama Cloud)
2932

3033
## Quick start (NAS / Pi / server)
3134

@@ -175,6 +178,12 @@ models:
175178
max_tokens: 8192
176179
base_url: https://dashscope-intl.aliyuncs.com/compatible-mode/v1
177180

181+
# Ollama Cloud or local Ollama (native /api/chat protocol)
182+
# ollama:
183+
# model: qwen3.5:32b # any model from ollama.com/search?c=cloud
184+
# api_key: ${OLLAMA_API_KEY} # required for cloud; optional for local
185+
# base_url: https://ollama.com # default; http://localhost:11434 for local
186+
178187
routing:
179188
default: deepseek # primary model — can be any configured model name
180189
fallback: gemini-flash-lite
@@ -186,6 +195,12 @@ routing:
186195

187196
tool_filter:
188197
top_k: 20 # top-K tools selected per request via vector similarity; 0 = disabled
198+
199+
# Ollama web search — gives any LLM access to real-time web results
200+
# web_search:
201+
# enabled: true
202+
# api_key: ${OLLAMA_API_KEY}
203+
# base_url: https://ollama.com # default
189204
```
190205

191206
### `config/mcp.json`
@@ -236,12 +251,12 @@ Plain text or Markdown injected as system prompt on every request.
236251

237252
| Priority | Role | When |
238253
|---|---|---|
239-
| 1 | `multimodal` | Message contains an image |
254+
| 1 | `multimodal` | Message contains an image or audio (voice transcription) |
240255
| 2 | `reasoner` | `/model <name>` override, or classifier detects complex reasoning |
241256
| 3 | `primary` | Default for all other messages |
242257
| 4 | `fallback` | Primary unavailable (5xx / 429 / network error) |
243258

244-
The classifier (`qwen-flash` by default) is a lightweight call with no history and no tools that returns `yes`/`no`. It only runs for messages longer than `classifier_min_length` characters (default: 100). Set `classifier_min_length: 0` to disable.
259+
The classifier (`qwen-flash` by default) is a lightweight call with no history and no tools that returns `yes`/`no`. It only runs for messages longer than `classifier_min_length` characters (default: 100). Input is truncated to 500 chars to save tokens. Set `classifier_min_length: 0` to disable.
245260

246261
All routing roles can be changed live via `/routing` — an inline keyboard menu. **Changes persist across restarts** in `config/routing.json`. On startup, the bot notifies the owner via Telegram if any routing role references an unavailable model.
247262

@@ -308,12 +323,15 @@ flowchart TD
308323
SC["Semantic compaction\ntopic clusters → per-cluster summary"]
309324
end
310325
326+
WebSearch["Web Search\n(Ollama API)"]
327+
311328
subgraph LLMs ["LLM Providers"]
312329
DS["deepseek"]
313330
DSR["deepseek-r1"]
314331
GL["gemini-flash-lite"]
315332
GM["gemini-flash"]
316333
QW["qwen-* (optional)"]
334+
OL["ollama (optional)"]
317335
end
318336
319337
subgraph Servers ["MCP Servers"]
@@ -322,7 +340,7 @@ flowchart TD
322340
S3["..."]
323341
end
324342
325-
User -->|"text / photo / reply"| Handler
343+
User -->|"text / photo / voice / reply"| Handler
326344
User -->|"forwarded messages"| FwdBuf
327345
FwdBuf <-->|"embed forwards"| Emb
328346
FwdBuf -->|"relevant forwards only\ncosine ≥ 0.25"| Handler
@@ -337,13 +355,16 @@ flowchart TD
337355
Agent -->|"cache hit → skip LLM"| Agent
338356
Agent --> Router
339357
Agent -->|"query embed · top-K filter"| MCP
358+
Agent <-->|"web_search tool"| WebSearch
359+
Handler -->|"voice → transcribe"| Agent
340360
MCP <-->|"embed tools at startup"| Emb
341361
MCP <-->|"tools/call"| Servers
342362
Router --> DS
343363
Router --> DSR
344364
Router --> GL
345365
Router --> GM
346366
Router --> QW
367+
Router --> OL
347368
```
348369

349370
See [CLAUDE.md](CLAUDE.md) for developer details.

0 commit comments

Comments
 (0)