27 Commits

Author SHA1 Message Date
melod1n 78932e82af shitton 2026-05-13 16:07:47 +03:00
melod1n a411c6874a shitton 2026-05-13 14:58:53 +03:00
melod1n bd548a9f43 shitton 2026-05-13 13:36:51 +03:00
melod1n 674c3cbd44 shitton 2026-05-13 12:05:55 +03:00
melod1n c5b61ee3d8 shitton 2026-05-13 10:18:54 +03:00
melod1n cd8d2683c0 shitton 2026-05-13 05:10:51 +03:00
melod1n 3848dd82d9 feat(ai): improve runtime capability reporting and context settings
Add explicit chat capability tracking, expose formatted runtime capabilities
in the info command, and support a max context size option for user AI settings.

Also update Ollama base URL resolution to use OLLAMA_ADDRESS and simplify
provider chat command execution.
2026-05-11 02:01:44 +03:00
melod1n d2464b9b21 app: wire new commands and update docs 2026-05-10 22:53:43 +03:00
melod1n 94d695e008 commands: localize generic bot commands 2026-05-10 22:53:32 +03:00
melod1n 3d14e3c0d5 commands: switch AI commands to unified runtime 2026-05-10 22:53:22 +03:00
melod1n 1b94760b21 ai: add RAG, speech-to-text and text-to-speech 2026-05-10 22:53:07 +03:00
melod1n 355ae8e5da ai: add common tool runtime and built-in tools 2026-05-10 22:52:48 +03:00
melod1n 32c35f54aa ai: add unified runtime and provider adapters 2026-05-10 22:52:35 +03:00
melod1n 4c2a5471df utils: add shared locks, queues, rendering and message helpers 2026-05-10 22:52:25 +03:00
melod1n d666244863 storage: persist message attachments and user AI settings 2026-05-10 22:52:10 +03:00
melod1n 28f67aefc2 config: add env schema and localization foundation 2026-05-10 22:51:52 +03:00
melod1n 986d4aca46 build: update dependencies and project config 2026-05-10 22:50:04 +03:00
melod1n 35354a86de refactor: centralize runtime config loading
- Move .env parsing and runtime config reload logic into Environment
- Reload runtime config and system prompt when source files change
- Gate unsafe eval and file tools behind explicit environment flags
- Rename datetime tool to get_datetime and improve tool prompts
- Return structured weather tool responses
- Preserve assistant thinking and aggregate tool calls across stream chunks
2026-05-03 19:45:18 +03:00
melod1n 2fc60806ff feat(ollama): add tool calling support
Add Ollama tool integration for web search, weather, datetime, filesystem operations, and shell evaluation. Implement multi-round tool call handling, streaming updates, thinking cleanup, and safer path validation for file tools.

Also add related environment configuration, command execution helper, MarkdownV2 cancel handling fixes, and remove unused TryAgain callback command.
2026-05-03 15:16:14 +03:00
melod1n 86b26813e2 update tsconfig.build.json 2026-05-01 07:11:10 +03:00
melod1n ca7caf7a51 update tsconfig.build.json 2026-05-01 07:08:57 +03:00
melod1n 13b41c3026 bump libs
migrate to typescript 6
remove ytdl feature
2026-05-01 07:05:17 +03:00
melod1n ac51702f00 update @mistralai lib 2026-05-01 05:35:37 +03:00
melod1n 0a34e15a22 fix gemini image analyze text 2026-05-01 05:27:03 +03:00
melod1n c24bc8394b fix 2026-05-01 05:18:39 +03:00
melod1n 0f91e43ea0 feat: add Ollama audio transcription and runtime config reload
- add audio capability reporting for Ollama models
- support Telegram voice messages via ffmpeg conversion and Ollama transcription
- add USE_SYSTEM_PROMPT toggle and runtime reloading of .env/system prompt settings
- support ollama_options.json for custom Ollama request options
- improve Telegram MarkdownV2 escaping and formatting preservation
- add environment setters for AI provider credentials and models
- show audio capability in info/model commands
2026-05-01 05:09:10 +03:00
melod1n 382e00ce31 bump libs 2026-05-01 04:54:11 +03:00
187 changed files with 6507 additions and 19854 deletions
+13 -45
View File
@@ -11,18 +11,6 @@ BOT_TOKEN=your_bot_token_here
# To get your ID: send /id command to the bot and use the "from id" value # To get your ID: send /id command to the bot and use the "from id" value
CREATOR_ID=your_user_id_here CREATOR_ID=your_user_id_here
# Database connection
# Leave empty for local SQLite in ~/.local/share/tg-chat-bot/database.db.
# Set DATA_PATH=data if you want to keep files inside the repo.
# Set to postgres://... for PostgreSQL.
# Set to :memory: for ephemeral in-memory SQLite.
DATABASE_URL=
DATA_PATH=
# Docker Compose image tag override
# Used by docker-compose.yml when pulling ghcr.io/melod1n/tg-chat-bot
IMAGE_TAG=1.0.0
# ============================================ # ============================================
# BOT SETTINGS (Optional) # BOT SETTINGS (Optional)
# ============================================ # ============================================
@@ -43,27 +31,9 @@ ONLY_FOR_CREATOR_MODE=false
# Use user names in AI prompts # Use user names in AI prompts
USE_NAMES_IN_PROMPT=true USE_NAMES_IN_PROMPT=true
# Disable all built-in local tools and keep only MCP tools
DISABLE_LOCAL_TOOLS=false
# Filter built-in local tools by name.
# LOCAL_TOOL_ALLOWLIST lets through only the listed tools.
# LOCAL_TOOL_DENYLIST removes the listed tools.
# Examples:
# LOCAL_TOOL_ALLOWLIST=get_datetime,web_search
# LOCAL_TOOL_DENYLIST=shell_execute,python_interpreter
LOCAL_TOOL_ALLOWLIST=
LOCAL_TOOL_DENYLIST=
# Custom system prompt for AI (or put it into data/SYSTEM_PROMPT.md) # Custom system prompt for AI (or put it into data/SYSTEM_PROMPT.md)
SYSTEM_PROMPT= SYSTEM_PROMPT=
# Tool ranker fallback policy:
# MAIN_MODEL - rank tools through the provider's chat model if a dedicated ranker target is missing or fails
# ALL_TOOLS - skip ranker fallback and allow all tools
# NO_TOOLS - skip ranker fallback and allow no tools
TOOL_RANKER_FALLBACK_POLICY=ALL_TOOLS
# Maximum photo size in pixels # Maximum photo size in pixels
MAX_PHOTO_SIZE=1280 MAX_PHOTO_SIZE=1280
@@ -74,6 +44,17 @@ LOCALES_DIR=locales
# AI MODELS CONFIGURATION (Optional) # AI MODELS CONFIGURATION (Optional)
# ============================================ # ============================================
# Google Gemini
GEMINI_API_KEY=
# google: official Gemini API via @google/genai; openai: OpenAI-compatible Gemini endpoint; auto: infer from GEMINI_BASE_URL
GEMINI_API_MODE=google
GEMINI_MODEL=gemini-2.5-flash
GEMINI_IMAGE_MODEL=gemini-2.5-flash-image
GEMINI_TRANSCRIPTION_MODEL=gemini-2.5-flash
GEMINI_TTS_MODEL=gemini-2.5-flash-preview-tts
GEMINI_TTS_VOICE=Kore
GEMINI_MAX_CONCURRENT_REQUESTS=3
# Mistral AI # Mistral AI
MISTRAL_API_KEY= MISTRAL_API_KEY=
MISTRAL_MODEL=mistral-small-latest MISTRAL_MODEL=mistral-small-latest
@@ -103,10 +84,6 @@ OLLAMA_MAX_CONCURRENT_REQUESTS=1
# OpenAI # OpenAI
OPENAI_API_KEY= OPENAI_API_KEY=
OPENAI_BASE_URL= OPENAI_BASE_URL=
# Backend mode:
# official = OpenAI responses API
# compatible = OpenAI-compatible chat.completions servers like llama.cpp
OPENAI_BACKEND=official
OPENAI_MODEL=gpt-4.1-nano OPENAI_MODEL=gpt-4.1-nano
OPENAI_IMAGE_MODEL=gpt-image-1-mini OPENAI_IMAGE_MODEL=gpt-image-1-mini
OPENAI_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe OPENAI_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe
@@ -115,29 +92,20 @@ OPENAI_TTS_VOICE=alloy
OPENAI_TTS_INSTRUCTIONS= OPENAI_TTS_INSTRUCTIONS=
OPENAI_MAX_CONCURRENT_REQUESTS=3 OPENAI_MAX_CONCURRENT_REQUESTS=3
# MCP servers
# JSON array or {"mcpServers": {"name": {...}}}
# Stdio example:
# MCP_SERVERS=[{"name":"local-tools","transport":"stdio","command":"node","args":["./mcp-server.js"]}]
# HTTP example:
# MCP_SERVERS=[{"name":"remote-tools","transport":"http","url":"https://example.com/mcp"}]
MCP_SERVERS=
# Per-capability AI endpoint overrides # Per-capability AI endpoint overrides
# Pattern: # Pattern:
# <PROVIDER>_<CAPABILITY>_MODEL= # <PROVIDER>_<CAPABILITY>_MODEL=
# <PROVIDER>_<CAPABILITY>_BASE_URL= # <PROVIDER>_<CAPABILITY>_BASE_URL=
# <PROVIDER>_<CAPABILITY>_API_KEY= # <PROVIDER>_<CAPABILITY>_API_KEY=
# #
# Providers: OLLAMA, MISTRAL, OPENAI # Providers: OLLAMA, GEMINI, MISTRAL, OPENAI
# Capabilities: CHAT, VISION, OCR, THINKING, EXTENDED_THINKING, TOOLS, AUDIO, # Capabilities: CHAT, VISION, OCR, THINKING, EXTENDED_THINKING, TOOLS, AUDIO,
# DOCUMENTS, OUTPUT_IMAGES, SPEECH_TO_TEXT, TEXT_TO_SPEECH # DOCUMENTS, OUTPUT_IMAGES, SPEECH_TO_TEXT, TEXT_TO_SPEECH
# Endpoint aliases are also supported: *_ENDPOINT and *_ADDRESS. # Endpoint aliases are also supported: *_ENDPOINT and *_ADDRESS.
# Provider-wide fallback endpoints: OPENAI_BASE_URL, MISTRAL_BASE_URL, # Provider-wide fallback endpoints: OPENAI_BASE_URL, MISTRAL_BASE_URL,
# OLLAMA_ADDRESS or OLLAMA_BASE_URL. # GEMINI_BASE_URL, OLLAMA_ADDRESS or OLLAMA_BASE_URL.
# Capability aliases are also supported: IMAGE, THINK, RAG, EMBEDDING, # Capability aliases are also supported: IMAGE, THINK, RAG, EMBEDDING,
# TRANSCRIPTION, STT, TTS. # TRANSCRIPTION, STT, TTS.
# Backend override: OPENAI_BACKEND=official|compatible.
# #
# Examples: # Examples:
# OPENAI_SPEECH_TO_TEXT_MODEL=gpt-4o-mini-transcribe # OPENAI_SPEECH_TO_TEXT_MODEL=gpt-4o-mini-transcribe
-55
View File
@@ -1,55 +0,0 @@
name: CI
on:
push:
pull_request:
jobs:
node:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20.19.0
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Typecheck
run: npm run typecheck
- name: Build
run: npm run build
bun:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint
run: bun run lint
- name: Typecheck
run: bun run typecheck
- name: Build
run: bun run build
-314
View File
@@ -1,314 +0,0 @@
# OPENAI Compatible Target Implementation
## Purpose
Add a separate execution path for OpenAI-compatible backends such as `llama.cpp`, while keeping the current official OpenAI path unchanged.
## Checklist
- [x] Add explicit OpenAI backend mode in config
- [x] Route OpenAI requests to separate official and compatible runners
- [x] Keep official OpenAI on `responses.create(...)`
- [x] Add compatible `chat.completions.create(...)` runner
- [x] Add compatible tool-call extractors
- [x] Add backend selection tests
- [x] Add basic memory/config regression coverage
- [x] Normalize compatible streaming tool-call assembly
- [x] Preserve file upload behavior in compatible backend
- [x] Guard unsupported OpenAI-only tools for compatible backend
- [x] Add environment docs and example config entries
- [x] Add real-server integration coverage for compatible backend
- [x] Revisit shared orchestration extraction for further deduplication
## Non-Goals
1. Do not change the current official OpenAI `responses.create(...)` behavior.
2. Do not auto-switch behavior only because `OPENAI_BASE_URL` is set.
3. Do not merge compatible backend quirks into the official OpenAI runner.
4. Do not remove or weaken existing tool ranking, memory, RAG, logging, or upload behavior.
## Current State
1. `src/ai/unified-ai-runner.openai.ts` currently uses the official `responses` API.
2. `src/ai/provider-adapters.ts` already has provider-specific adapters and tool/result mapping.
3. `src/ai/provider-adapter-contract.ts` already contains `responses`-style extractors.
4. `src/ai/openai-chat-message.ts` currently models `responses`-style messages, not `chat.completions` tool messages.
5. `src/ai/unified-ai-request-pipeline.ts` prepares chat context and runtime state before the model call.
6. `src/ai/ai-runtime-target.ts` resolves provider targets, base URLs, models, and keys.
7. `src/ai/unified-ai-runner.tool-ranker.ts` already uses a `chat.completions`-style call path, which is closer to compatible backends.
## Target Architecture
1. Official OpenAI backend stays on `responses.create(...)`.
2. Compatible OpenAI backend uses `chat.completions.create(...)`.
3. Backend selection is explicit through config, for example `OPENAI_BACKEND=official|compatible`.
4. Shared preparation logic remains common.
5. Transport-specific request formatting and response parsing are split.
## Configuration Design
1. Add a new config value `OPENAI_BACKEND`.
2. Allowed values should be `official` and `compatible`.
3. Default must be `official`.
4. Keep `OPENAI_BASE_URL` as a transport setting only.
5. `OPENAI_BASE_URL` must not imply compatible mode by itself.
6. Extend environment schema and runtime config to expose this value.
7. Update env docs and example env files.
## Step 1: Config and Target Selection
1. Update `src/common/environment.ts`.
2. Add a new environment field for backend mode.
3. Add setters if the codebase uses runtime env mutation in tests.
4. Update the startup schema and runtime snapshot.
5. Add tests for default value and explicit `compatible` selection.
Expected result:
- Official OpenAI stays unchanged by default.
- Explicit `OPENAI_BACKEND=compatible` selects the new execution path.
## Step 2: Split Runner Selection
1. Update the unified AI execution entry point.
2. Add a small backend selector for OpenAI targets.
3. Route official mode to the current runner.
4. Route compatible mode to a new compatible runner.
5. Keep other providers untouched.
Expected result:
- One codepath for official OpenAI.
- One codepath for OpenAI-compatible servers.
## Step 3: Shared Orchestration Extraction
1. Identify logic that is identical for both OpenAI branches.
2. Extract common orchestration into a shared helper where possible.
3. Keep these pieces shared:
- memory prompt injection
- tool ranking
- tool loop control
- logging and timing
- cancellation handling
- file upload post-processing
- document RAG preparation and cleanup
4. Keep transport-specific pieces separate:
- request shape
- response parsing
- tool result message shape
- streaming event parsing
Expected result:
- Less duplicate logic.
- Cleaner separation between official and compatible behavior.
## Step 4: Compatible Message Model
1. Update `src/ai/openai-chat-message.ts` or create a sibling type file for compatible chat messages.
2. Model `system`, `user`, `assistant`, and `tool` roles explicitly.
3. Support `tool_calls` on assistant messages.
4. Support `tool_call_id` on tool result messages.
5. Preserve support for text and multimodal user content where the backend supports it.
6. Avoid forcing `responses` output types into `chat.completions`.
Expected result:
- Compatible runner can build valid `chat.completions` message arrays.
## Step 5: Compatible Contract Extractors
1. Extend `src/ai/provider-adapter-contract.ts`.
2. Add extractors for `chat.completions` tool calls.
3. Add extractors for `chat.completions` streaming tool call deltas.
4. Keep existing `responses` extractors intact.
5. Normalize tool call IDs, names, and argument text the same way as existing extractors.
6. Ensure arguments are always represented as JSON text for the tool loop.
Expected result:
- Compatible runner can parse tool calls from both normal and streaming responses.
## Step 6: Compatible Provider Adapter
1. Update `src/ai/provider-adapters.ts`.
2. Add a separate adapter or branch for OpenAI-compatible chat.completions behavior.
3. Reuse existing tool ranking where safe.
4. Make `appendToolResults(...)` emit `role: "tool"` messages with `tool_call_id`.
5. Keep official OpenAI adapter outputting `function_call_output`.
6. Keep Mistral and Ollama unchanged.
Expected result:
- Each backend uses the tool result shape it expects.
## Step 7: Compatible Runner Implementation
1. Create a new file such as `src/ai/unified-ai-runner.openai-compatible.ts`.
2. Use `openai.chat.completions.create(...)`.
3. Pass `messages`, `tools`, `model`, `stream`, and `signal`.
4. Map system prompt and memory prompt into the `messages` array correctly.
5. Keep the tool loop structure from the current runner.
6. Append assistant tool-call messages and tool result messages between rounds.
7. Continue until no tool calls remain or max rounds is reached.
Expected result:
- Compatible backends can complete multi-round tool flows.
## Step 8: Tool Call Loop Semantics
1. Preserve `MAX_TOOL_ROUNDS`.
2. Preserve tool ranking before each round.
3. Preserve memory tool selection.
4. Preserve file search injection when document RAG is active.
5. Preserve file upload post-processing.
6. Preserve max-rounds warnings and continuation decisions.
7. Keep the final text visible in the stream message exactly as today.
Expected result:
- Compatible backend behaves like the current runner from the users perspective.
## Step 9: Streaming Behavior
1. Implement streaming event handling for `chat.completions`.
2. Parse text deltas and append them to `TelegramStreamMessage`.
3. Parse `delta.tool_calls` and keep incremental tool-call state.
4. Update status text when tool usage starts and ends.
5. Keep image generation and file-search status handling if the backend emits compatible signals.
6. Finalize the stream only after the terminal completion event.
Expected result:
- Streaming works without losing tool call state.
## Step 10: Tool Result Handling
1. After each tool execution round, append tool results using the compatible message format.
2. Ensure each tool result keeps the correct `tool_call_id`.
3. Preserve the existing file upload hook.
4. If upload fails, convert the failure into a tool result error string.
5. Preserve the same tool memory map behavior.
Expected result:
- The backend receives a valid message history for the next round.
## Step 11: Prompt and Memory Injection
1. Keep `buildSystemInstruction(...)` as the source of system prompt assembly.
2. Keep `buildUserMemoryPrompt(...)` injected as a separate block.
3. Preserve the explicit separation between assistant memory and user memory.
4. Preserve the `user.md` and `system.md` memory layout.
5. Ensure compatible backend receives the same semantic prompt content.
Expected result:
- Memory behavior stays identical across official and compatible backends.
## Step 12: Tool Ranking Compatibility
1. Review `src/ai/unified-ai-runner.tool-ranker.ts`.
2. Verify whether the current JSON response handling is safe for compatible backends.
3. If a backend cannot guarantee strict JSON mode, add a fallback parser.
4. Keep ranking inputs and outputs consistent across both branches.
5. Do not weaken tool selection heuristics.
Expected result:
- Tool ranking remains deterministic enough for both branches.
## Step 13: File Search and RAG
1. Keep document RAG preparation in the request pipeline.
2. Keep vector store preparation for official OpenAI.
3. Decide whether compatible backend supports file search or needs a no-op fallback.
4. If unsupported, guard the tool list so the compatible backend never receives unsupported tools.
5. Keep cleanup behavior for temporary artifacts.
Expected result:
- Compatible backend does not receive tools it cannot execute.
## Step 14: Error Handling
1. Preserve abort handling.
2. Preserve response failure handling.
3. Preserve stream error handling.
4. Surface backend-specific incompatibilities as explicit errors.
5. Do not silently fall back from compatible to official mode.
6. Keep logs actionable.
Expected result:
- Failures are obvious and debuggable.
## Step 15: Logging and Observability
1. Keep the current AI logs and duration tracking.
2. Add backend mode to log metadata.
3. Log tool calls, tool outputs, and round transitions in both branches.
4. Preserve existing observability hooks.
5. Add explicit labels for official vs compatible runs.
Expected result:
- Debugging remains easy after the split.
## Step 16: Tests
1. Add unit tests for backend selection.
2. Add unit tests for compatible message conversion.
3. Add unit tests for compatible tool call extraction.
4. Add integration tests for a tool-call round trip using mocked `chat.completions`.
5. Add tests proving the official `responses` path is unchanged.
6. Add tests for streaming tool call parsing if the backend supports it.
7. Add tests for fallback behavior in the tool ranker if needed.
Expected result:
- Both branches are covered and regressions are visible quickly.
## Step 17: Suggested File Changes
1. `src/common/environment.ts`
2. `src/ai/ai-runtime-target.ts`
3. `src/ai/unified-ai-request-pipeline.ts`
4. `src/ai/unified-ai-runner.openai.ts`
5. `src/ai/unified-ai-runner.openai-compatible.ts`
6. `src/ai/provider-adapter-contract.ts`
7. `src/ai/provider-adapters.ts`
8. `src/ai/openai-chat-message.ts`
9. `src/ai/unified-ai-runner.tool-ranker.ts`
10. `test/*.test.mjs`
11. `.env.example`
12. Documentation files for backend selection
## Implementation Order
1. [x] Add config flag and wire it through environment parsing.
2. [x] Add backend selection logic.
3. [x] Add compatible message and extractor support.
4. [x] Create the compatible runner.
5. [x] Reuse shared orchestration where possible.
6. [x] Wire tests.
7. [x] Verify official behavior is unchanged.
8. [x] Verify compatible backend works with a real OpenAI-compatible server.
## Verification Plan
1. Run unit tests.
2. Run integration tests.
3. Verify official OpenAI path still uses `responses.create(...)`.
4. Verify compatible path uses `chat.completions.create(...)`.
5. Verify a `llama.cpp`-style server can complete a tool loop.
6. Verify memory tools still work.
7. Verify document RAG and file upload behavior do not regress.
## Risks
1. Some OpenAI-compatible servers do not support every official OpenAI feature.
2. Streaming tool call deltas may differ across providers.
3. JSON-mode assumptions in the ranker may not hold for all compatible servers.
4. Tool schema filtering may need backend-specific allowlists.
5. Message conversion mistakes can break tool loops silently if not tested.
## Acceptance Criteria
1. Official OpenAI behavior is unchanged.
2. Compatible backend can run a full chat loop with tools.
3. Tool calls are correctly extracted and executed.
4. Tool results are appended in the correct format.
5. Memory injection still works.
6. Document RAG and file upload behavior remain functional or fail explicitly.
7. Tests cover both branches.
## Final Note
The key design rule is simple: keep official OpenAI `responses` behavior intact, and introduce OpenAI-compatible `chat.completions` behavior as a separate backend mode with its own parsing and message shape.
+16 -49
View File
@@ -1,50 +1,12 @@
# Telegram Chat Bot # Telegram Chat Bot
Bot for Telegram with a lot of commands and AI (Ollama/Mistral/OpenAI) written in TypeScript + NodeJS/Bun runtime + SQLite/PostgreSQL/in-memory storage Bot for Telegram with a lot of commands and AI (Ollama/Gemini/Mistral) written in TypeScript + NodeJS/Bun runtime + Drizzle ORM (SQLite DB)
## Quick Start ## Quick Start
```bash ```bash
cp .env.example .env cp .env.example .env
# Edit .env: add BOT_TOKEN, CREATOR_ID and configure optional AI models (MISTRAL_API_KEY, OPENAI_API_KEY, OLLAMA_ADDRESS) # Edit .env: add BOT_TOKEN, CREATOR_ID and configure optional AI models (GEMINI_API_KEY, MISTRAL_API_KEY, OLLAMA_ADDRESS)
# For OpenAI-compatible servers (llama.cpp, etc.), set OPENAI_BACKEND=compatible and OPENAI_BASE_URL.
# Optional: set DATABASE_URL to postgres://... for PostgreSQL or :memory: for ephemeral SQLite.
# Optional: set DATA_PATH if you want to override the default local storage directory.
```
**With Bun (Recommended):**
```bash
bun install
bun run build && bun start
```
**With Node.js:**
```bash
npm install
npm run build && npm start
```
The bot initializes and migrates its database schema automatically on startup.
`/exportdb` sends the SQLite file when available, plus a `.sql` dump and a JSON backup.
`/importdb` restores the database from the JSON backup format.
MCP tool servers can be configured through `MCP_SERVERS` in `.env`. Use a JSON array with `stdio` or `http` transports. Example:
```bash
MCP_SERVERS=[{"name":"local-tools","transport":"stdio","command":"node","args":["./mcp-server.js"]}]
```
If you want to disable all built-in local tools and use only MCP tools, set:
```bash
DISABLE_LOCAL_TOOLS=true
```
If you want a partial filter instead, use tool names:
```bash
LOCAL_TOOL_ALLOWLIST=get_datetime,web_search
LOCAL_TOOL_DENYLIST=shell_execute,python_interpreter
``` ```
For local Ollama document RAG, install an embedding model locally and set it in `.env`: For local Ollama document RAG, install an embedding model locally and set it in `.env`:
@@ -54,19 +16,24 @@ ollama pull nomic-embed-text
OLLAMA_EMBEDDING_MODEL=nomic-embed-text OLLAMA_EMBEDDING_MODEL=nomic-embed-text
``` ```
Tool ranker fallback is configurable via `TOOL_RANKER_FALLBACK_POLICY`: **With Bun (Recommended):**
```bash
bun install
bunx drizzle-kit generate && bunx drizzle-kit migrate
bun run build && bun start
```
- `MAIN_MODEL` - use the provider's main chat model to rank tools if a dedicated ranker target is missing or fails **With Node.js:**
- `ALL_TOOLS` - skip tool ranking fallback and allow all tools ```bash
- `NO_TOOLS` - skip tool ranking fallback and allow no tools npm install
npx drizzle-kit generate && npx drizzle-kit migrate
The default is `ALL_TOOLS`. npm run build && npm start
```
**With Docker Compose:** **With Docker Compose:**
```bash ```bash
docker compose up -d docker compose up -d
``` ```
Set `IMAGE_TAG` in `.env` if you want to override the pinned release tag used by `docker-compose.yml`.
**With Docker:** **With Docker:**
```bash ```bash
@@ -82,13 +49,13 @@ docker run -d --env-file .env -v $(pwd)/data:/config/data tg-bot-bun
## Requirements ## Requirements
- Node.js >= 20.19 OR Bun >= 1.0 - Node.js >= 20 OR Bun >= 1.0
- Docker (optional) - Docker (optional)
## Features ## Features
- AI chat (Mistral, Ollama, OpenAI) - AI chat (Gemini, Mistral, Ollama)
- Local document RAG for Ollama without third-party providers - Local document RAG for Ollama without third-party providers
- Custom answers and commands - Custom answers and commands
- Admin management - Admin management
+286 -197
View File
@@ -5,17 +5,17 @@
"": { "": {
"name": "tg-chat-bot", "name": "tg-chat-bot",
"dependencies": { "dependencies": {
"@google/genai": "^2.0.0",
"@libsql/client": "^0.17.3", "@libsql/client": "^0.17.3",
"@mistralai/mistralai": "^2.2.1", "@mistralai/mistralai": "^2.2.1",
"@napi-rs/canvas": "^1.0.0", "@napi-rs/canvas": "^1.0.0",
"axios": "^1.16.1", "axios": "^1.16.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"emoji-regex": "^10.6.0", "emoji-regex": "^10.6.0",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"ollama": "^0.6.3", "ollama": "^0.6.3",
"openai": "^6.38.0", "openai": "^6.37.0",
"pg": "^8.21.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"systeminformation": "^5.31.6", "systeminformation": "^5.31.6",
@@ -24,48 +24,77 @@
"zod": "^4.4.3", "zod": "^4.4.3",
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@types/bun": "^1.3.13",
"@types/bun": "^1.3.14",
"@types/fluent-ffmpeg": "^2.1.28", "@types/fluent-ffmpeg": "^2.1.28",
"@types/node": "^25.9.1", "@types/node": "^25.6.1",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"eslint": "^9.39.4", "@typescript/native-preview": "^7.0.0-beta",
"typescript": "^6.0.3", "drizzle-kit": "^0.31.10",
"typescript-eslint": "^8.59.4",
}, },
}, },
}, },
"packages": { "packages": {
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@google/genai": ["@google/genai@2.0.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-6XpO+YbGutXkm5QgR7NZktISxSz0dw3pSs9NtCUQwvhJc1eyA3KhdKhE/0Uaxp3a6eul3LC0SKau1bXymjOKUg=="],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
@@ -117,6 +146,8 @@
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@libsql/client": ["@libsql/client@0.17.3", "", { "dependencies": { "@libsql/core": "^0.17.3", "@libsql/hrana-client": "^0.10.0", "js-base64": "^3.7.5", "libsql": "^0.5.28", "promise-limit": "^2.7.0" } }, "sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA=="], "@libsql/client": ["@libsql/client@0.17.3", "", { "dependencies": { "@libsql/core": "^0.17.3", "@libsql/hrana-client": "^0.10.0", "js-base64": "^3.7.5", "libsql": "^0.5.28", "promise-limit": "^2.7.0" } }, "sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA=="],
"@libsql/core": ["@libsql/core@0.17.3", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg=="], "@libsql/core": ["@libsql/core@0.17.3", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg=="],
@@ -171,76 +202,86 @@
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/fluent-ffmpeg": ["@types/fluent-ffmpeg@2.1.28", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw=="], "@types/fluent-ffmpeg": ["@types/fluent-ffmpeg@2.1.28", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/node": ["@types/node@25.6.1", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g=="],
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.4", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/type-utils": "8.59.4", "@typescript-eslint/utils": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.4", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A=="], "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260421.2", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260421.2", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260421.2", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260421.2", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260421.2", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260421.2", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260421.2", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260421.2" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-CmajHI25HpVWE9R1XFoxr+cphJPxoYD3eFioQtAvXYkMFKnLdICMS9pXre9Pybizb75ejRxjKD5/CVG055rEIg=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.4", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ=="], "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260421.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fHv1r3ZmVo6zxuAIFmuX3w9QxbcauoG0SsWhmDwm6VmRubLlOJIcmTtlmV3JAb9oOnq8LuzZljzT7Q39fSMQDw=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.4", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.4", "@typescript-eslint/types": "^8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg=="], "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260421.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-KWTR6xbW9t+JS7D5DQIzo75pqVXVWUxF9PMv/+S6xsnOjCVd6g0ixHcFpFMJMKSUQpGPr8Z5f7b8ks6LHW01jg=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4" } }, "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q=="], "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260421.2", "", { "os": "linux", "cpu": "arm" }, "sha512-BWLQO3nemLDSV5PoE5GPHe1dU9Dth77Kv8/cle9Ujcp4LhPo0KincdPqFH/qKeU/xvW25mgFueflZ1nc4rKuww=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.4", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA=="], "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260421.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-VLMEuml3BhUb+jaL0TXQ4xvVODxJF+RhkI+tBWvlynsJI4khTXEiwWh+wPOJrsfBRYFRMXEu28Odl/HXkYze8w=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA=="], "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260421.2", "", { "os": "linux", "cpu": "x64" }, "sha512-qUrJWTB5/wv4wnRG0TRXElAxc2kykNiRNyEIEqBbLmzDlrcvAW7RRy8MXoY1ZyTiKGMu14itZ3x9oW6+blFpRw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.59.4", "", {}, "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q=="], "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260421.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Rc6NsWlZmCs5YUKVzKgwoBOoRUGsPzct4BDMRX0csD1devLBBc4AbUXWKsJRbpwIAnqMO1ld4sNHEb+wXgfNHQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.4", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.4", "@typescript-eslint/tsconfig-utils": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag=="], "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260421.2", "", { "os": "win32", "cpu": "x64" }, "sha512-GQv1+dya1t6EqF2Cpsb+xoozovdX10JUSf6Kl/8xNkTapzmlHd+uMr+8ku3jIASTxoRGn0Mklgjj3MDKrOTuLg=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" } }, "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="], "async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha1-x57Zf380y48robyXkLzDZkdLS3k="], "asynckit": ["asynckit@0.4.0", "", {}, "sha1-x57Zf380y48robyXkLzDZkdLS3k="],
"axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="], "axios": ["axios@1.16.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -249,16 +290,14 @@
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="], "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -267,10 +306,16 @@
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
@@ -281,65 +326,53 @@
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"fluent-ffmpeg": ["fluent-ffmpeg@2.1.3", "", { "dependencies": { "async": "^0.2.9", "which": "^1.1.1" } }, "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q=="], "fluent-ffmpeg": ["fluent-ffmpeg@2.1.3", "", { "dependencies": { "async": "^0.2.9", "which": "^1.1.1" } }, "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q=="],
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="],
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
@@ -347,43 +380,31 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"jsonfile": ["jsonfile@5.0.0", "", { "dependencies": { "universalify": "^0.1.2" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w=="], "jsonfile": ["jsonfile@5.0.0", "", { "dependencies": { "universalify": "^0.1.2" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="], "libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -391,65 +412,43 @@
"mime-types": ["mime-types@2.1.29", "", { "dependencies": { "mime-db": "1.46.0" } }, "sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ=="], "mime-types": ["mime-types@2.1.29", "", { "dependencies": { "mime-db": "1.46.0" } }, "sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ=="],
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"ollama": ["ollama@0.6.3", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg=="], "ollama": ["ollama@0.6.3", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg=="],
"openai": ["openai@6.38.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g=="], "openai": ["openai@6.37.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pg": ["pg@8.21.0", "", { "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.14.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA=="], "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="],
"pg-connection-string": ["pg-connection-string@2.13.0", "", {}, "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="],
"pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="],
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="],
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
@@ -457,7 +456,13 @@
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -469,41 +474,37 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"systeminformation": ["systeminformation@5.31.6", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA=="], "systeminformation": ["systeminformation@5.31.6", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Uv2b2uGGM6ns+26czgW2cYRabYdnswM0ddSOOlryHOaelzsmDSet1iM/NT7VOYxW8x/BW+HkY+b1Ve2pLTSGSA=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"twemoji": ["twemoji@14.0.2", "", { "dependencies": { "fs-extra": "^8.0.1", "jsonfile": "^5.0.0", "twemoji-parser": "14.0.0", "universalify": "^0.1.2" } }, "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA=="], "twemoji": ["twemoji@14.0.2", "", { "dependencies": { "fs-extra": "^8.0.1", "jsonfile": "^5.0.0", "twemoji-parser": "14.0.0", "universalify": "^0.1.2" } }, "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA=="],
"twemoji-parser": ["twemoji-parser@14.0.0", "", {}, "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA=="], "twemoji-parser": ["twemoji-parser@14.0.0", "", {}, "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"typescript-eslint": ["typescript-eslint@8.59.4", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.4", "@typescript-eslint/parser": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ=="],
"typescript-telegram-bot-api": ["typescript-telegram-bot-api@0.16.0", "", { "dependencies": { "axios": "^1.7.7", "form-data": "^4.0.1" } }, "sha512-NaAjXucQiZ87U8La/IMaWDOghbMlJzfMbU4rG8ppFpgPOvEwat/zfN5BM+J2QDvKVGN87qJ+1nELnkm3ctSLnQ=="], "typescript-telegram-bot-api": ["typescript-telegram-bot-api@0.16.0", "", { "dependencies": { "axios": "^1.7.7", "form-data": "^4.0.1" } }, "sha512-NaAjXucQiZ87U8La/IMaWDOghbMlJzfMbU4rG8ppFpgPOvEwat/zfN5BM+J2QDvKVGN87qJ+1nELnkm3ctSLnQ=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
@@ -511,13 +512,11 @@
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
@@ -525,13 +524,17 @@
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@libsql/isomorphic-ws/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "@libsql/isomorphic-ws/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
@@ -539,19 +542,11 @@
"@types/fluent-ffmpeg/@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], "@types/fluent-ffmpeg/@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
"@types/pg/@types/node": ["@types/node@25.6.1", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g=="],
"@types/qrcode/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], "@types/qrcode/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"@types/ws/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], "@types/ws/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "bun-types/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"bun-types/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -559,30 +554,124 @@
"libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
"pg/pg-protocol": ["pg-protocol@1.14.0", "", {}, "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="], "protobufjs/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
"string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"typescript-telegram-bot-api/axios": ["axios@1.16.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"tsx/get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"@types/fluent-ffmpeg/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "@types/fluent-ffmpeg/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@types/pg/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"@types/qrcode/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "@types/qrcode/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "protobufjs/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
"yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
services: services:
tgchatbot: tgchatbot:
container_name: tgchatbot container_name: tgchatbot
image: ghcr.io/melod1n/tg-chat-bot:${IMAGE_TAG:-1.0.0} image: ghcr.io/melod1n/tg-chat-bot:latest
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
+17
View File
@@ -0,0 +1,17 @@
import "dotenv/config";
import {defineConfig} from "drizzle-kit";
import path from "node:path";
const dataPath = process.env.DATA_PATH
?? (process.env.IS_DOCKER === "true" ? "/" + path.join("config", "data") : "data");
const dbFileName = process.env.DB_FILE_NAME ?? "database.db";
export default defineConfig({
out: "./drizzle",
schema: "./src/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: process.env.DB_FILE_NAME
// url: process.env.DB_PATH ? "file:" + process.env.DB_PATH : "file:" + path.join(dataPath, dbFileName),
},
});
+15 -26
View File
@@ -1,42 +1,31 @@
import js from "@eslint/js"; const tsParser = require("@typescript-eslint/parser");
import {defineConfig} from "eslint/config"; const tsPlugin = require("@typescript-eslint/eslint-plugin");
import tseslint from "typescript-eslint";
export default defineConfig( module.exports = [
{ {
ignores: [ ignores: [
"dist/**", "dist/**",
"data/**",
"node_modules/**", "node_modules/**",
"**/*.tsbuildinfo",
], ],
}, },
js.configs.recommended,
tseslint.configs.recommended,
{ {
files: ["src/**/*.ts"], files: ["**/*.ts"],
languageOptions: {
parser: tsParser,
ecmaVersion: "latest",
sourceType: "module",
},
linterOptions: { linterOptions: {
reportUnusedDisableDirectives: "off", reportUnusedDisableDirectives: "off",
}, },
plugins: {
"@typescript-eslint": tsPlugin,
},
rules: { rules: {
"no-console": "error",
"no-control-regex": "off",
"no-case-declarations": "off",
"no-useless-escape": "off",
"no-extra-boolean-cast": "off",
"quotes": ["error", "double", {avoidEscape: true}],
"semi": ["error", "always"],
"prefer-const": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off",
"no-unused-vars": "off", "no-unused-vars": "off",
"quotes": "warn",
"semi": "error",
}, },
}, },
{ ];
files: ["src/logging/logger.ts"],
rules: {
"no-console": "off",
},
},
);
+4 -17
View File
@@ -8,13 +8,6 @@
}, },
"providerChoice.default": "Default", "providerChoice.default": "Default",
"errorText": "⚠️ An error occurred.", "errorText": "⚠️ An error occurred.",
"pipelineFallback.generic": "⚠️ I had to skip part of the request, but I can continue.",
"pipelineFallback.notifyUser": "⚠️ I hit a problem and need to continue with a fallback.",
"pipelineFallback.failRequest": "⚠️ I could not finish this request.",
"pipelineFallback.documentRag": "⚠️ Document retrieval failed, so I will answer without RAG.",
"pipelineFallback.speechToText": "⚠️ Speech transcription failed, so I will continue without the audio transcript.",
"pipelineFallback.textToSpeech": "⚠️ Text-to-speech failed, so I will continue without audio output.",
"pipelineFallback.toolLoop": "⚠️ Tool execution failed, so I will continue without that tool.",
"waitThinkText": "⏳ Let me think...", "waitThinkText": "⏳ Let me think...",
"analyzingPictureText": "🔍 Analyzing the image...", "analyzingPictureText": "🔍 Analyzing the image...",
"analyzingPicturesText": "🔍 Analyzing the images...", "analyzingPicturesText": "🔍 Analyzing the images...",
@@ -69,21 +62,18 @@
"userSettingsResponseLanguageSelectionTitle": "Response Language Selection", "userSettingsResponseLanguageSelectionTitle": "Response Language Selection",
"userSettingsContextSizeSelectionTitle": "Context Size Selection", "userSettingsContextSizeSelectionTitle": "Context Size Selection",
"userSettingsVoiceModeSelectionTitle": "Voice Message Mode Selection", "userSettingsVoiceModeSelectionTitle": "Voice Message Mode Selection",
"userSettingsImageOutputSelectionTitle": "Image Output Mode Selection",
"userSettingsTierLabel": "Tier", "userSettingsTierLabel": "Tier",
"userSettingsAiProviderLabel": "AI provider", "userSettingsAiProviderLabel": "AI provider",
"userSettingsInterfaceLanguageLabel": "Interface language", "userSettingsInterfaceLanguageLabel": "Interface language",
"userSettingsResponseLanguageLabel": "LLM response language", "userSettingsResponseLanguageLabel": "LLM response language",
"userSettingsContextSizeLabel": "Context size", "userSettingsContextSizeLabel": "Context size",
"userSettingsVoiceModeLabel": "Voice messages", "userSettingsVoiceModeLabel": "Voice messages",
"userSettingsImageOutputLabel": "Image output",
"userSettingsBackButtonText": "Back", "userSettingsBackButtonText": "Back",
"userSettingsAiProviderButtonPrefix": "AI provider", "userSettingsAiProviderButtonPrefix": "AI provider",
"userSettingsInterfaceLanguageButtonPrefix": "Interface language", "userSettingsInterfaceLanguageButtonPrefix": "Interface language",
"userSettingsResponseLanguageButtonPrefix": "Response language", "userSettingsResponseLanguageButtonPrefix": "Response language",
"userSettingsContextSizeButtonPrefix": "Context", "userSettingsContextSizeButtonPrefix": "Context",
"userSettingsVoiceModeButtonPrefix": "Voice", "userSettingsVoiceModeButtonPrefix": "Voice",
"userSettingsImageOutputButtonPrefix": "Image output",
"userSettingsCreatorTierText": "Creator", "userSettingsCreatorTierText": "Creator",
"userSettingsAdminTierText": "Admin", "userSettingsAdminTierText": "Admin",
"userSettingsUserTierText": "User", "userSettingsUserTierText": "User",
@@ -91,8 +81,6 @@
"userSettingsContextSizeDefaultText": "Default", "userSettingsContextSizeDefaultText": "Default",
"userSettingsVoiceModeExecuteText": "Run through AI", "userSettingsVoiceModeExecuteText": "Run through AI",
"userSettingsVoiceModeTranscriptText": "Show transcript only", "userSettingsVoiceModeTranscriptText": "Show transcript only",
"userSettingsImageOutputPhotoText": "As photo",
"userSettingsImageOutputDocumentText": "As document",
"startingImageGenText": "🌈 Starting image generation...", "startingImageGenText": "🌈 Starting image generation...",
"imageGenText": "🌈 Generating image...", "imageGenText": "🌈 Generating image...",
"finalizingImageGenText": "🌈 Finalizing image generation...", "finalizingImageGenText": "🌈 Finalizing image generation...",
@@ -140,7 +128,6 @@
"getImageGenDoneText.default": "👨‍🎨 Image generated.", "getImageGenDoneText.default": "👨‍🎨 Image generated.",
"getErrorText.withReason": "{errorText} Reason:\n{reason}", "getErrorText.withReason": "{errorText} Reason:\n{reason}",
"getUseToolText.python": "👨‍💻 Running `Python`", "getUseToolText.python": "👨‍💻 Running `Python`",
"getUseToolText.codeInterpreter": "👨‍💻 Running `Code Interpreter`",
"getUseToolText.default": "🔧 Using tool `{name}`", "getUseToolText.default": "🔧 Using tool `{name}`",
"getAnalyzingDocumentText.default": "🔍 Analyzing the document...", "getAnalyzingDocumentText.default": "🔍 Analyzing the document...",
"getAnalyzingDocumentText.single": "🔍 Analyzing document: `{name}`", "getAnalyzingDocumentText.single": "🔍 Analyzing document: `{name}`",
@@ -148,7 +135,6 @@
"getPreparingRAGText.default": "🔍 Preparing RAG for the document...", "getPreparingRAGText.default": "🔍 Preparing RAG for the document...",
"getPreparingRAGText.single": "🔍 Preparing RAG for document: `{name}`", "getPreparingRAGText.single": "🔍 Preparing RAG for document: `{name}`",
"getPreparingRAGText.many": "🔍 Preparing RAG for documents: {names}", "getPreparingRAGText.many": "🔍 Preparing RAG for documents: {names}",
"getSelectingToolsText": "🧩 Choosing the right tools...",
"getBuildingRAGIndexText.default": "🧠 Building RAG index...", "getBuildingRAGIndexText.default": "🧠 Building RAG index...",
"getBuildingRAGIndexText.withModel": "🧠 Building RAG index: `{modelName}`.", "getBuildingRAGIndexText.withModel": "🧠 Building RAG index: `{modelName}`.",
"queueNoneText": "none", "queueNoneText": "none",
@@ -183,9 +169,6 @@
"getWhenPluralUnitText": "{unit}s", "getWhenPluralUnitText": "{unit}s",
"getWhenDurationText": "{prefix}{value} {unit}", "getWhenDurationText": "{prefix}{value} {unit}",
"commandDescriptions": { "commandDescriptions": {
"aiAudit": "Inspect AI request audit and artifacts",
"aiMetrics": "Show AI observability counters",
"aiRequests": "Show recent AI requests",
"ae": "evaluation", "ae": "evaluation",
"adminsAdd": "Add user to admins", "adminsAdd": "Add user to admins",
"adminsRemove": "Remove user from admins", "adminsRemove": "Remove user from admins",
@@ -195,6 +178,10 @@
"debug": "Returns msg (or reply) as json", "debug": "Returns msg (or reply) as json",
"dice": "Sends random or specific dice", "dice": "Sends random or specific dice",
"distort": "Distortion of picture", "distort": "Distortion of picture",
"geminiChat": "Chat with AI (Gemini)",
"geminiGetModel": "Get current Gemini model",
"geminiListModels": "List all Gemini models",
"geminiSetModel": "Set Gemini model",
"help": "Show list of commands", "help": "Show list of commands",
"id": "ID of chat, user and reply (if replied to any message)", "id": "ID of chat, user and reply (if replied to any message)",
"ignore": "Bot will ignore user", "ignore": "Bot will ignore user",
+5 -18
View File
@@ -8,14 +8,7 @@
}, },
"providerChoice.default": "По умолчанию", "providerChoice.default": "По умолчанию",
"errorText": "⚠️ Произошла ошибка.", "errorText": "⚠️ Произошла ошибка.",
"pipelineFallback.generic": "⚠️ Мне пришлось пропустить часть запроса, но я могу продолжить.", "waitThinkText": "⏳ Думаю...",
"pipelineFallback.notifyUser": "⚠️ Возникла проблема, и я продолжу с запасным вариантом.",
"pipelineFallback.failRequest": "⚠️ Я не смог завершить этот запрос.",
"pipelineFallback.documentRag": "⚠️ Не удалось получить документы, поэтому я отвечу без RAG.",
"pipelineFallback.speechToText": "⚠️ Не удалось распознать речь, поэтому я продолжу без расшифровки аудио.",
"pipelineFallback.textToSpeech": "⚠️ Не удалось выполнить синтез речи, поэтому я продолжу без аудио.",
"pipelineFallback.toolLoop": "⚠️ Не удалось выполнить инструменты, поэтому я продолжу без них.",
"waitThinkText": "⏳ Дайте-ка подумать...",
"analyzingPictureText": "🔍 Анализирую изображение...", "analyzingPictureText": "🔍 Анализирую изображение...",
"analyzingPicturesText": "🔍 Анализирую изображения...", "analyzingPicturesText": "🔍 Анализирую изображения...",
"reasoningText": "🤔 Рассуждаю...", "reasoningText": "🤔 Рассуждаю...",
@@ -95,21 +88,18 @@
"userSettingsResponseLanguageSelectionTitle": "Выбор языка ответов", "userSettingsResponseLanguageSelectionTitle": "Выбор языка ответов",
"userSettingsContextSizeSelectionTitle": "Выбор размера контекста", "userSettingsContextSizeSelectionTitle": "Выбор размера контекста",
"userSettingsVoiceModeSelectionTitle": "Режим голосовых сообщений", "userSettingsVoiceModeSelectionTitle": "Режим голосовых сообщений",
"userSettingsImageOutputSelectionTitle": "Режим отправки изображений",
"userSettingsTierLabel": "Уровень", "userSettingsTierLabel": "Уровень",
"userSettingsAiProviderLabel": "AI-провайдер", "userSettingsAiProviderLabel": "AI-провайдер",
"userSettingsInterfaceLanguageLabel": "Язык интерфейса", "userSettingsInterfaceLanguageLabel": "Язык интерфейса",
"userSettingsResponseLanguageLabel": "Язык ответов LLM", "userSettingsResponseLanguageLabel": "Язык ответов LLM",
"userSettingsContextSizeLabel": "Размер контекста", "userSettingsContextSizeLabel": "Размер контекста",
"userSettingsVoiceModeLabel": "Голосовые сообщения", "userSettingsVoiceModeLabel": "Голосовые сообщения",
"userSettingsImageOutputLabel": "Изображения",
"userSettingsBackButtonText": "Назад", "userSettingsBackButtonText": "Назад",
"userSettingsAiProviderButtonPrefix": "AI-провайдер", "userSettingsAiProviderButtonPrefix": "AI-провайдер",
"userSettingsInterfaceLanguageButtonPrefix": "Язык интерфейса", "userSettingsInterfaceLanguageButtonPrefix": "Язык интерфейса",
"userSettingsResponseLanguageButtonPrefix": "Язык ответов", "userSettingsResponseLanguageButtonPrefix": "Язык ответов",
"userSettingsContextSizeButtonPrefix": "Контекст", "userSettingsContextSizeButtonPrefix": "Контекст",
"userSettingsVoiceModeButtonPrefix": "Голосовые", "userSettingsVoiceModeButtonPrefix": "Голосовые",
"userSettingsImageOutputButtonPrefix": "Изображения",
"userSettingsCreatorTierText": "Создатель", "userSettingsCreatorTierText": "Создатель",
"userSettingsAdminTierText": "Админ", "userSettingsAdminTierText": "Админ",
"userSettingsUserTierText": "Пользователь", "userSettingsUserTierText": "Пользователь",
@@ -117,8 +107,6 @@
"userSettingsContextSizeDefaultText": "По умолчанию", "userSettingsContextSizeDefaultText": "По умолчанию",
"userSettingsVoiceModeExecuteText": "Выполнять через ИИ", "userSettingsVoiceModeExecuteText": "Выполнять через ИИ",
"userSettingsVoiceModeTranscriptText": "Только расшифровка", "userSettingsVoiceModeTranscriptText": "Только расшифровка",
"userSettingsImageOutputPhotoText": "Как фото",
"userSettingsImageOutputDocumentText": "Как документ",
"startingImageGenText": "🌈 Запускаю генерацию изображения...", "startingImageGenText": "🌈 Запускаю генерацию изображения...",
"imageGenText": "🌈 Генерирую изображение...", "imageGenText": "🌈 Генерирую изображение...",
"finalizingImageGenText": "🌈 Завершаю генерацию изображения...", "finalizingImageGenText": "🌈 Завершаю генерацию изображения...",
@@ -166,7 +154,6 @@
"getImageGenDoneText.default": "👨‍🎨 Изображение сгенерировано.", "getImageGenDoneText.default": "👨‍🎨 Изображение сгенерировано.",
"getErrorText.withReason": "{errorText} Причина:\n{reason}", "getErrorText.withReason": "{errorText} Причина:\n{reason}",
"getUseToolText.python": "👨‍💻 Запускаю `Python`", "getUseToolText.python": "👨‍💻 Запускаю `Python`",
"getUseToolText.codeInterpreter": "👨‍💻 Запускаю `Code Interpreter`",
"getUseToolText.default": "🔧 Использую инструмент `{name}`", "getUseToolText.default": "🔧 Использую инструмент `{name}`",
"getAnalyzingDocumentText.default": "🔍 Анализирую документ...", "getAnalyzingDocumentText.default": "🔍 Анализирую документ...",
"getAnalyzingDocumentText.single": "🔍 Анализирую документ: `{name}`", "getAnalyzingDocumentText.single": "🔍 Анализирую документ: `{name}`",
@@ -174,7 +161,6 @@
"getPreparingRAGText.default": "🔍 Готовлю RAG для документа...", "getPreparingRAGText.default": "🔍 Готовлю RAG для документа...",
"getPreparingRAGText.single": "🔍 Готовлю RAG для документа: `{name}`", "getPreparingRAGText.single": "🔍 Готовлю RAG для документа: `{name}`",
"getPreparingRAGText.many": "🔍 Готовлю RAG для документов: {names}", "getPreparingRAGText.many": "🔍 Готовлю RAG для документов: {names}",
"getSelectingToolsText": "🧩 Выбираю подходящие инструменты...",
"getBuildingRAGIndexText.default": "🧠 Строю RAG-индекс...", "getBuildingRAGIndexText.default": "🧠 Строю RAG-индекс...",
"getBuildingRAGIndexText.withModel": "🧠 Строю RAG-индекс: `{modelName}`.", "getBuildingRAGIndexText.withModel": "🧠 Строю RAG-индекс: `{modelName}`.",
"queueNoneText": "нет", "queueNoneText": "нет",
@@ -209,9 +195,6 @@
"getWhenPluralUnitText": "{unit}", "getWhenPluralUnitText": "{unit}",
"getWhenDurationText": "{prefix}{value} {unit}", "getWhenDurationText": "{prefix}{value} {unit}",
"commandDescriptions": { "commandDescriptions": {
"aiRequests": "Показать последние AI-запросы",
"aiAudit": "Показать аудит AI-запроса и артефакты",
"aiMetrics": "Показать счётчики AI-обсервабилити",
"ae": "вычисление", "ae": "вычисление",
"adminsAdd": "Добавить пользователя в администраторы", "adminsAdd": "Добавить пользователя в администраторы",
"adminsRemove": "Удалить пользователя из администраторов", "adminsRemove": "Удалить пользователя из администраторов",
@@ -221,6 +204,10 @@
"debug": "Вернуть msg или reply в JSON", "debug": "Вернуть msg или reply в JSON",
"dice": "Отправить случайный или конкретный дайс", "dice": "Отправить случайный или конкретный дайс",
"distort": "Искажение изображения", "distort": "Искажение изображения",
"geminiChat": "Чат с AI (Gemini)",
"geminiGetModel": "Показать текущую модель Gemini",
"geminiListModels": "Показать все модели Gemini",
"geminiSetModel": "Установить модель Gemini",
"help": "Показать список команд", "help": "Показать список команд",
"id": "ID чата, пользователя и ответа", "id": "ID чата, пользователя и ответа",
"ignore": "Бот будет игнорировать пользователя", "ignore": "Бот будет игнорировать пользователя",
+1 -18
View File
@@ -8,14 +8,7 @@
}, },
"providerChoice.default": "За замовчуванням", "providerChoice.default": "За замовчуванням",
"errorText": "⚠️ Сталася помилка.", "errorText": "⚠️ Сталася помилка.",
"pipelineFallback.generic": "⚠️ Мені довелося пропустити частину запиту, але я можу продовжити.", "waitThinkText": "⏳ Думаю...",
"pipelineFallback.notifyUser": "⚠️ Виникла проблема, і я продовжу із запасним варіантом.",
"pipelineFallback.failRequest": "⚠️ Я не зміг завершити цей запит.",
"pipelineFallback.documentRag": "⚠️ Не вдалося отримати документи, тому я відповім без RAG.",
"pipelineFallback.speechToText": "⚠️ Не вдалося розпізнати мовлення, тому я продовжу без розшифровки аудіо.",
"pipelineFallback.textToSpeech": "⚠️ Не вдалося виконати синтез мовлення, тому я продовжу без аудіо.",
"pipelineFallback.toolLoop": "⚠️ Не вдалося виконати інструменти, тому я продовжу без них.",
"waitThinkText": "⏳ Дайте-но подумати...",
"analyzingPictureText": "🔍 Аналізую зображення...", "analyzingPictureText": "🔍 Аналізую зображення...",
"analyzingPicturesText": "🔍 Аналізую зображення...", "analyzingPicturesText": "🔍 Аналізую зображення...",
"reasoningText": "🤔 Міркую...", "reasoningText": "🤔 Міркую...",
@@ -94,21 +87,18 @@
"userSettingsResponseLanguageSelectionTitle": "Вибір мови відповідей", "userSettingsResponseLanguageSelectionTitle": "Вибір мови відповідей",
"userSettingsContextSizeSelectionTitle": "Вибір розміру контексту", "userSettingsContextSizeSelectionTitle": "Вибір розміру контексту",
"userSettingsVoiceModeSelectionTitle": "Режим голосових повідомлень", "userSettingsVoiceModeSelectionTitle": "Режим голосових повідомлень",
"userSettingsImageOutputSelectionTitle": "Режим надсилання зображень",
"userSettingsTierLabel": "Рівень", "userSettingsTierLabel": "Рівень",
"userSettingsAiProviderLabel": "AI-провайдер", "userSettingsAiProviderLabel": "AI-провайдер",
"userSettingsInterfaceLanguageLabel": "Мова інтерфейсу", "userSettingsInterfaceLanguageLabel": "Мова інтерфейсу",
"userSettingsResponseLanguageLabel": "Мова відповідей LLM", "userSettingsResponseLanguageLabel": "Мова відповідей LLM",
"userSettingsContextSizeLabel": "Розмір контексту", "userSettingsContextSizeLabel": "Розмір контексту",
"userSettingsVoiceModeLabel": "Голосові повідомлення", "userSettingsVoiceModeLabel": "Голосові повідомлення",
"userSettingsImageOutputLabel": "Зображення",
"userSettingsBackButtonText": "Назад", "userSettingsBackButtonText": "Назад",
"userSettingsAiProviderButtonPrefix": "AI-провайдер", "userSettingsAiProviderButtonPrefix": "AI-провайдер",
"userSettingsInterfaceLanguageButtonPrefix": "Мова інтерфейсу", "userSettingsInterfaceLanguageButtonPrefix": "Мова інтерфейсу",
"userSettingsResponseLanguageButtonPrefix": "Мова відповідей", "userSettingsResponseLanguageButtonPrefix": "Мова відповідей",
"userSettingsContextSizeButtonPrefix": "Контекст", "userSettingsContextSizeButtonPrefix": "Контекст",
"userSettingsVoiceModeButtonPrefix": "Голосові", "userSettingsVoiceModeButtonPrefix": "Голосові",
"userSettingsImageOutputButtonPrefix": "Зображення",
"userSettingsCreatorTierText": "Творець", "userSettingsCreatorTierText": "Творець",
"userSettingsAdminTierText": "Адмін", "userSettingsAdminTierText": "Адмін",
"userSettingsUserTierText": "Користувач", "userSettingsUserTierText": "Користувач",
@@ -116,8 +106,6 @@
"userSettingsContextSizeDefaultText": "За замовчуванням", "userSettingsContextSizeDefaultText": "За замовчуванням",
"userSettingsVoiceModeExecuteText": "Виконувати через AI", "userSettingsVoiceModeExecuteText": "Виконувати через AI",
"userSettingsVoiceModeTranscriptText": "Лише розшифровка", "userSettingsVoiceModeTranscriptText": "Лише розшифровка",
"userSettingsImageOutputPhotoText": "Як фото",
"userSettingsImageOutputDocumentText": "Як документ",
"startingImageGenText": "🌈 Запускаю генерацію зображення...", "startingImageGenText": "🌈 Запускаю генерацію зображення...",
"imageGenText": "🌈 Генерую зображення...", "imageGenText": "🌈 Генерую зображення...",
"finalizingImageGenText": "🌈 Завершую генерацію зображення...", "finalizingImageGenText": "🌈 Завершую генерацію зображення...",
@@ -165,7 +153,6 @@
"getImageGenDoneText.default": "👨‍🎨 Зображення згенеровано.", "getImageGenDoneText.default": "👨‍🎨 Зображення згенеровано.",
"getErrorText.withReason": "{errorText} Причина:\n{reason}", "getErrorText.withReason": "{errorText} Причина:\n{reason}",
"getUseToolText.python": "👨‍💻 Запускаю `Python`", "getUseToolText.python": "👨‍💻 Запускаю `Python`",
"getUseToolText.codeInterpreter": "👨‍💻 Запускаю `Code Interpreter`",
"getUseToolText.default": "🔧 Використовую інструмент `{name}`", "getUseToolText.default": "🔧 Використовую інструмент `{name}`",
"getAnalyzingDocumentText.default": "🔍 Аналізую документ...", "getAnalyzingDocumentText.default": "🔍 Аналізую документ...",
"getAnalyzingDocumentText.single": "🔍 Аналізую документ: `{name}`", "getAnalyzingDocumentText.single": "🔍 Аналізую документ: `{name}`",
@@ -173,7 +160,6 @@
"getPreparingRAGText.default": "🔍 Готую RAG для документа...", "getPreparingRAGText.default": "🔍 Готую RAG для документа...",
"getPreparingRAGText.single": "🔍 Готую RAG для документа: `{name}`", "getPreparingRAGText.single": "🔍 Готую RAG для документа: `{name}`",
"getPreparingRAGText.many": "🔍 Готую RAG для документів: {names}", "getPreparingRAGText.many": "🔍 Готую RAG для документів: {names}",
"getSelectingToolsText": "🧩 Вибираю підхожі інструменти...",
"getBuildingRAGIndexText.default": "🧠 Будую RAG-індекс...", "getBuildingRAGIndexText.default": "🧠 Будую RAG-індекс...",
"getBuildingRAGIndexText.withModel": "🧠 Будую RAG-індекс: `{modelName}`.", "getBuildingRAGIndexText.withModel": "🧠 Будую RAG-індекс: `{modelName}`.",
"queueNoneText": "немає", "queueNoneText": "немає",
@@ -208,9 +194,6 @@
"getWhenPluralUnitText": "{unit}", "getWhenPluralUnitText": "{unit}",
"getWhenDurationText": "{prefix}{value} {unit}", "getWhenDurationText": "{prefix}{value} {unit}",
"commandDescriptions": { "commandDescriptions": {
"aiRequests": "Показати останні AI-запити",
"aiAudit": "Показати аудит AI-запиту та артефакти",
"aiMetrics": "Показати лічильники AI-спостережуваності",
"help": "Показати список команд", "help": "Показати список команд",
"settings": "Налаштування користувача", "settings": "Налаштування користувача",
"start": "Запустити бота", "start": "Запустити бота",
+1976 -1496
View File
File diff suppressed because it is too large Load Diff
+11 -18
View File
@@ -4,42 +4,35 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "tsc -p tsconfig.build.json", "build": "tsgo -p tsconfig.json",
"test": "npm run build && node --test test/*.test.mjs",
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"start": "node dist/index.js", "start": "node dist/index.js",
"bun:start": "bun run src/index.ts" "bun:start": "bun run src/index.ts"
}, },
"dependencies": { "dependencies": {
"@libsql/client": "^0.17.3", "@google/genai": "^2.0.0",
"@mistralai/mistralai": "^2.2.1", "@mistralai/mistralai": "^2.2.1",
"openai": "^6.37.0",
"ollama": "^0.6.3",
"typescript-telegram-bot-api": "^0.16.0",
"@libsql/client": "^0.17.3",
"@napi-rs/canvas": "^1.0.0", "@napi-rs/canvas": "^1.0.0",
"axios": "^1.16.1", "axios": "^1.16.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-orm": "0.45.2", "drizzle-orm": "0.45.2",
"emoji-regex": "^10.6.0", "emoji-regex": "^10.6.0",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"ollama": "^0.6.3",
"openai": "^6.38.0",
"pg": "^8.21.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"systeminformation": "^5.31.6", "systeminformation": "^5.31.6",
"twemoji": "^14.0.2", "twemoji": "^14.0.2",
"typescript-telegram-bot-api": "^0.16.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@types/bun": "^1.3.13",
"@types/bun": "^1.3.14",
"@types/fluent-ffmpeg": "^2.1.28", "@types/fluent-ffmpeg": "^2.1.28",
"@types/node": "^25.9.1", "@types/node": "^25.6.1",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"eslint": "^9.39.4", "drizzle-kit": "^0.31.10",
"typescript": "^6.0.3", "@typescript/native-preview": "^7.0.0-beta"
"typescript-eslint": "^8.59.4"
} }
} }
+48 -36
View File
@@ -1,12 +1,13 @@
import {Mistral} from "@mistralai/mistralai"; import {Mistral} from "@mistralai/mistralai";
import {GoogleGenAI} from "@google/genai";
import {Ollama} from "ollama"; import {Ollama} from "ollama";
import {OpenAI} from "openai"; import {OpenAI} from "openai";
import {Environment} from "../common/environment.js"; import {Environment} from "../common/environment";
import {AiModelCapabilities} from "../model/ai-model-capabilities.js"; import {AiModelCapabilities} from "../model/ai-model-capabilities";
import {AiProvider} from "../model/ai-provider.js"; import {AiProvider} from "../model/ai-provider";
export type AiCapabilityName = keyof AiModelCapabilities; export type AiCapabilityName = keyof AiModelCapabilities;
export type AiRuntimePurpose = AiCapabilityName | "chat" | "memoryCompress"; export type AiRuntimePurpose = AiCapabilityName | "chat";
export type AiRuntimeTarget = { export type AiRuntimeTarget = {
provider: AiProvider; provider: AiProvider;
@@ -14,9 +15,12 @@ export type AiRuntimeTarget = {
model: string; model: string;
baseUrl?: string; baseUrl?: string;
apiKey?: string; apiKey?: string;
systemPromptAdditions?: string | null;
}; };
export type GeminiApiMode = "google" | "openai";
const GEMINI_OPENAI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/";
const PURPOSE_SUFFIXES: Record<AiRuntimePurpose, string[]> = { const PURPOSE_SUFFIXES: Record<AiRuntimePurpose, string[]> = {
chat: ["CHAT"], chat: ["CHAT"],
vision: ["VISION", "IMAGE"], vision: ["VISION", "IMAGE"],
@@ -24,8 +28,6 @@ const PURPOSE_SUFFIXES: Record<AiRuntimePurpose, string[]> = {
thinking: ["THINKING", "THINK"], thinking: ["THINKING", "THINK"],
extendedThinking: ["EXTENDED_THINKING", "THINKING", "THINK"], extendedThinking: ["EXTENDED_THINKING", "THINKING", "THINK"],
tools: ["TOOLS", "CHAT"], tools: ["TOOLS", "CHAT"],
memoryCompress: ["MEMORY_COMPRESS"],
toolRank: ["TOOL_RANK", "TOOL_RANKER"],
audio: ["AUDIO"], audio: ["AUDIO"],
documents: ["DOCUMENTS", "RAG", "EMBEDDING"], documents: ["DOCUMENTS", "RAG", "EMBEDDING"],
outputImages: ["OUTPUT_IMAGES", "IMAGE"], outputImages: ["OUTPUT_IMAGES", "IMAGE"],
@@ -69,18 +71,13 @@ function modelEnvNames(provider: AiProvider, purpose: AiRuntimePurpose): string[
return PURPOSE_SUFFIXES[purpose].map(suffix => `${prefix}_${suffix}_MODEL`); return PURPOSE_SUFFIXES[purpose].map(suffix => `${prefix}_${suffix}_MODEL`);
} }
function systemPromptEnvNames(provider: AiProvider, purpose: AiRuntimePurpose): string[] {
const prefix = providerPrefix(provider);
return PURPOSE_SUFFIXES[purpose].flatMap(suffix => [
`${prefix}_${suffix}_SYSTEM_PROMPT_ADDITIONS`,
`${prefix}_${suffix}_SYSTEM_PROMPT`,
]);
}
export function getProviderBaseUrl(provider: AiProvider): string | undefined { export function getProviderBaseUrl(provider: AiProvider): string | undefined {
switch (provider) { switch (provider) {
case AiProvider.OLLAMA: case AiProvider.OLLAMA:
return env("OLLAMA_ADDRESS"); return env("OLLAMA_ADDRESS");
case AiProvider.GEMINI:
return env("GEMINI_BASE_URL") ?? env("GEMINI_ENDPOINT")
?? (Environment.GEMINI_API_MODE === "openai" ? GEMINI_OPENAI_BASE_URL : undefined);
case AiProvider.MISTRAL: case AiProvider.MISTRAL:
return env("MISTRAL_BASE_URL") ?? env("MISTRAL_ENDPOINT"); return env("MISTRAL_BASE_URL") ?? env("MISTRAL_ENDPOINT");
case AiProvider.OPENAI: case AiProvider.OPENAI:
@@ -92,6 +89,8 @@ export function getProviderApiKey(provider: AiProvider): string | undefined {
switch (provider) { switch (provider) {
case AiProvider.OLLAMA: case AiProvider.OLLAMA:
return Environment.OLLAMA_API_KEY; return Environment.OLLAMA_API_KEY;
case AiProvider.GEMINI:
return Environment.GEMINI_API_KEY;
case AiProvider.MISTRAL: case AiProvider.MISTRAL:
return Environment.MISTRAL_API_KEY; return Environment.MISTRAL_API_KEY;
case AiProvider.OPENAI: case AiProvider.OPENAI:
@@ -118,6 +117,19 @@ export function getDefaultModelForPurpose(provider: AiProvider, purpose: AiRunti
default: default:
return Environment.OLLAMA_CHAT_MODEL; return Environment.OLLAMA_CHAT_MODEL;
} }
case AiProvider.GEMINI:
switch (purpose) {
case "vision":
case "ocr":
case "outputImages":
return Environment.GEMINI_IMAGE_MODEL;
case "speechToText":
return Environment.GEMINI_TRANSCRIPTION_MODEL;
case "textToSpeech":
return Environment.GEMINI_TTS_MODEL;
default:
return Environment.GEMINI_MODEL;
}
case AiProvider.MISTRAL: case AiProvider.MISTRAL:
switch (purpose) { switch (purpose) {
case "speechToText": case "speechToText":
@@ -151,28 +163,8 @@ export function resolveAiRuntimeTarget(
?? getDefaultModelForPurpose(provider, purpose); ?? getDefaultModelForPurpose(provider, purpose);
const baseUrl = firstEnv(endpointEnvNames(provider, purpose)) ?? getProviderBaseUrl(provider); const baseUrl = firstEnv(endpointEnvNames(provider, purpose)) ?? getProviderBaseUrl(provider);
const apiKey = firstEnv(apiKeyEnvNames(provider, purpose)) ?? getProviderApiKey(provider); const apiKey = firstEnv(apiKeyEnvNames(provider, purpose)) ?? getProviderApiKey(provider);
const systemPromptAdditions = firstEnv(systemPromptEnvNames(provider, purpose));
return {provider, purpose, model, baseUrl, apiKey, systemPromptAdditions}; return {provider, purpose, model, baseUrl, apiKey};
}
function hasExplicitTargetConfig(provider: AiProvider, purpose: AiRuntimePurpose): boolean {
const prefix = providerPrefix(provider);
return [
...endpointEnvNames(provider, purpose),
...apiKeyEnvNames(provider, purpose),
...modelEnvNames(provider, purpose),
...systemPromptEnvNames(provider, purpose),
].some(name => !!env(name)) || !!env(`${prefix}_${PURPOSE_SUFFIXES[purpose][0]}_MODEL`);
}
export function resolveOptionalAiRuntimeTarget(
provider: AiProvider,
purpose: AiRuntimePurpose,
modelOverride?: string,
): AiRuntimeTarget | undefined {
if (!hasExplicitTargetConfig(provider, purpose)) return undefined;
return resolveAiRuntimeTarget(provider, purpose, modelOverride);
} }
export function sameRuntimeEndpoint(left: AiRuntimeTarget, right: AiRuntimeTarget): boolean { export function sameRuntimeEndpoint(left: AiRuntimeTarget, right: AiRuntimeTarget): boolean {
@@ -188,6 +180,26 @@ export function createOpenAiClient(target: AiRuntimeTarget): OpenAI {
}); });
} }
export function getGeminiApiMode(target?: AiRuntimeTarget): GeminiApiMode {
if (Environment.GEMINI_API_MODE === "openai") return "openai";
if (Environment.GEMINI_API_MODE === "google") return "google";
if ((target?.baseUrl ?? "").includes("/openai")) return "openai";
return "google";
}
export function createGeminiOpenAiClient(target: AiRuntimeTarget): OpenAI {
return createOpenAiClient({
...target,
baseUrl: target.baseUrl ?? GEMINI_OPENAI_BASE_URL,
});
}
export function createGoogleGenAiClient(target: AiRuntimeTarget): GoogleGenAI {
return new GoogleGenAI({
apiKey: target.apiKey,
});
}
export function createMistralClient(target: AiRuntimeTarget): Mistral { export function createMistralClient(target: AiRuntimeTarget): Mistral {
return new Mistral({ return new Mistral({
apiKey: target.apiKey, apiKey: target.apiKey,
+25 -1
View File
@@ -1,5 +1,6 @@
import {AiToolCall} from "./tool-types"; import {AiToolCall} from "./tool-types";
import {OllamaChatMessage} from "./ollama-chat-message"; import {OllamaChatMessage} from "./ollama-chat-message";
import {GeminiMessage} from "./gemini-chat-message";
import {MistralChatMessage} from "./mistral-chat-message"; import {MistralChatMessage} from "./mistral-chat-message";
import {MessageAudioPart, MessageImagePart} from "../common/message-part"; import {MessageAudioPart, MessageImagePart} from "../common/message-part";
import {OpenAIChatMessage} from "./openai-chat-message"; import {OpenAIChatMessage} from "./openai-chat-message";
@@ -30,6 +31,27 @@ export function asOllamaChatMessage(message: ChatMessage): OllamaChatMessage {
}; };
} }
// export function asGeminiChatMessage(message: ChatMessage): GeminiMessage {
// if (message.images) {
// return {
// role: message.role,
// content: message.images.map(() => {
// return {
// type: "image",
// };
// })
// };
// }
//
// return {
// role: message.role,
// content: {
// type: "text",
// text: message.content,
// },
// };
// }
export function asMistralChatMessage(message: ChatMessage): MistralChatMessage { export function asMistralChatMessage(message: ChatMessage): MistralChatMessage {
return { return {
role: message.role, role: message.role,
@@ -42,4 +64,6 @@ export function asMistralChatMessage(message: ChatMessage): MistralChatMessage {
// //
// } // }
// } // }
export type AiChatMessage = OpenAIChatMessage | OllamaChatMessage | MistralChatMessage;
export type AiChatMessage = | OpenAIChatMessage | OllamaChatMessage | MistralChatMessage | GeminiMessage;
-358
View File
@@ -1,358 +0,0 @@
import {Message} from "typescript-telegram-bot-api";
import type {
ResponseInputMessageContentList,
ResponseOutputMessage,
ResponseOutputText,
} from "openai/resources/responses/responses";
import {AiProvider} from "../model/ai-provider";
import {MessageStore} from "../common/message-store";
import {collectReplyChainText} from "../util/utils";
import type {AiDownloadedFile} from "./telegram-attachments";
import type {MessageAudioPart, MessageImagePart, MessagePart} from "../common/message-part";
import type {UserAiResponseLanguage} from "../common/user-ai-settings";
import {getResponseLanguageInstruction} from "../common/user-ai-settings";
import {pythonInterpreterToolPrompt} from "./tools/python-interpretator";
import type {AttachmentKind, AiRuntimeTarget, RuntimeConfigSnapshot} from "./unified-ai-runner.shared";
import type {OpenAIChatMessage} from "./openai-chat-message";
import type {MistralChatMessage} from "./mistral-chat-message";
import type {OllamaChatMessage} from "./ollama-chat-message";
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
export type ConversationAttachment = {
kind: AttachmentKind;
data: string;
mimeType: string;
fileName?: string;
};
export type ConversationTurn = {
bot: boolean;
name?: string;
langCode?: string;
userName?: string;
content: string;
deletedByBotAt?: number | null;
attachments: ConversationAttachment[];
documentNames?: string[];
};
export type ConversationSnapshot = {
turns: ConversationTurn[];
imageCount: number;
systemInstruction: string;
};
function buildAttachmentFromImage(image: MessageImagePart): ConversationAttachment {
return {
kind: "image",
data: image.data,
mimeType: image.mimeType || "image/jpeg",
fileName: "image.jpg",
};
}
function buildAttachmentFromAudio(audio: MessageAudioPart): ConversationAttachment {
return {
kind: "audio",
data: audio.data,
mimeType: audio.mimeType || "audio/mpeg",
fileName: "audio.bin",
};
}
function buildConversationAttachments(part: MessagePart): ConversationAttachment[] {
const attachments: ConversationAttachment[] = [];
for (const image of part.imageParts ?? []) {
attachments.push(buildAttachmentFromImage(image));
}
for (const audio of part.audioParts ?? []) {
attachments.push(buildAttachmentFromAudio(audio));
}
for (const document of part.documents ?? []) {
attachments.push({
kind: "document",
data: document,
mimeType: "application/octet-stream",
fileName: "document.bin",
});
}
for (const video of part.videos ?? []) {
attachments.push({
kind: "video",
data: video,
mimeType: "video/mp4",
fileName: "video.mp4",
});
}
for (const videoNote of part.videoNotes ?? []) {
attachments.push({
kind: "video-note",
data: videoNote,
mimeType: "video/mp4",
fileName: "video-note.mp4",
});
}
return attachments;
}
function attachmentCounts(attachments: ConversationAttachment[]): Record<AttachmentKind, number> {
return attachments.reduce<Record<AttachmentKind, number>>((counts, attachment) => {
counts[attachment.kind] += 1;
return counts;
}, {
image: 0,
document: 0,
audio: 0,
video: 0,
"video-note": 0,
});
}
function attachmentSummary(attachments: ConversationAttachment[]): string {
const counts = attachmentCounts(attachments);
const lines = Object.entries(counts)
.filter(([, count]) => count > 0)
.map(([kind, count]) => `- ${kind}: ${count}`);
if (!lines.length) return "";
return ["[attachments]:", ...lines].join("\n");
}
function namesSummary(kind: string, names: string[]): string {
const filtered = names.map(name => name.trim()).filter(Boolean);
if (!filtered.length) return "";
return [`[${kind}]:`, ...filtered.map(name => `- ${name}`)].join("\n");
}
function supportedAttachmentKinds(provider: AiProvider, bot: boolean): Set<AttachmentKind> {
if (bot) return new Set<AttachmentKind>();
switch (provider) {
case AiProvider.OPENAI:
return new Set<AttachmentKind>(["image", "audio", "document", "video", "video-note"]);
case AiProvider.MISTRAL:
return new Set<AttachmentKind>(["image"]);
case AiProvider.OLLAMA:
return new Set<AttachmentKind>();
}
return new Set<AttachmentKind>();
}
function renderContentText(
turn: ConversationTurn,
provider: AiProvider,
includeNames: boolean,
): string {
const parts = [turn.content.trim()];
const supported = supportedAttachmentKinds(provider, turn.bot);
const unsupported = turn.attachments.filter(attachment => !supported.has(attachment.kind));
if (includeNames && !turn.bot) {
parts.unshift([
"[user_info]:",
`name: ${turn.name ?? ""}`.trimEnd(),
`username: @${turn.userName ?? ""}`.trimEnd(),
"",
].join("\n"));
}
if (turn.bot && turn.deletedByBotAt) {
parts.push("[message_state]: deleted_by_bot");
}
if (turn.documentNames?.length) {
parts.push(namesSummary("documents", turn.documentNames));
}
if (unsupported.length) {
parts.push(attachmentSummary(unsupported));
}
return parts.filter(part => part.trim().length > 0).join("\n\n").trim();
}
function buildOpenAiOutputText(text: string): ResponseOutputText {
return {
type: "output_text",
text,
annotations: [],
};
}
function buildOpenAiInputMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): OpenAIChatMessage {
const text = renderContentText(turn, provider, includeNames);
const content: ResponseInputMessageContentList = [
{
type: "input_text",
text,
},
];
for (const attachment of turn.attachments.filter(item => item.kind === "image")) {
content.push({
type: "input_image",
image_url: `data:${attachment.mimeType};base64,${attachment.data}`,
detail: "auto",
});
}
return {
type: "message",
role: "user",
content,
};
}
function buildOpenAiAssistantMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): ResponseOutputMessage {
const text = renderContentText(turn, provider, includeNames);
return {
id: `msg_${Date.now()}`,
type: "message",
role: "assistant",
status: "completed",
phase: "final_answer",
content: [buildOpenAiOutputText(text)],
};
}
function buildOpenAiMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): OpenAIChatMessage {
return turn.bot
? buildOpenAiAssistantMessage(turn, provider, includeNames)
: buildOpenAiInputMessage(turn, provider, includeNames);
}
function buildMistralMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): MistralChatMessage {
const text = renderContentText(turn, provider, includeNames);
if (turn.bot) {
return {
role: "assistant",
content: [{type: "text", text}],
};
}
return {
role: "user",
content: [
{type: "text", text},
...turn.attachments
.filter(attachment => attachment.kind === "image")
.map(attachment => ({
type: "image_url" as const,
imageUrl: `data:${attachment.mimeType};base64,${attachment.data}`,
})),
],
};
}
function buildOllamaMessage(turn: ConversationTurn, provider: AiProvider, includeNames: boolean): OllamaChatMessage {
const text = renderContentText(turn, provider, includeNames);
return {
role: turn.bot ? "assistant" : "user",
content: text,
images: turn.bot ? undefined : turn.attachments.filter(attachment => attachment.kind === "image").map(attachment => attachment.data),
};
}
function buildSystemInstruction(
config: RuntimeConfigSnapshot,
responseLanguage: UserAiResponseLanguage,
includePythonToolPrompt: boolean,
additions?: string | null,
memoryInstruction?: string | null,
): string {
return [
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
additions?.trim() ? additions.trim() : null,
memoryInstruction?.trim() ? memoryInstruction.trim() : null,
includePythonToolPrompt ? pythonInterpreterToolPrompt : null,
].filter(Boolean).join("\n\n");
}
export async function buildConversationSnapshot(
msg: Message,
textOverride: string,
downloads: AiDownloadedFile[],
config: RuntimeConfigSnapshot,
runtimeTarget: AiRuntimeTarget,
responseLanguage: UserAiResponseLanguage,
includePythonToolPrompt: boolean,
): Promise<ConversationSnapshot> {
const storedMsg = await MessageStore.get(msg.chat.id, msg.message_id);
const messageParts = await collectReplyChainText({triggerMsg: storedMsg ?? msg, downloads});
if (messageParts.length && textOverride.trim()) {
const latest = messageParts[0];
if (!latest.bot) latest.content = textOverride.trim();
}
const turns = messageParts
.reverse()
.map(part => ({
bot: part.bot,
name: part.name,
langCode: part.langCode,
userName: part.userName,
content: part.content,
deletedByBotAt: part.deletedByBotAt,
attachments: buildConversationAttachments(part),
documentNames: part.documentNames,
}));
const imageCount = turns.reduce((sum, turn) => {
if (turn.bot) return sum;
return sum + turn.attachments.filter(attachment => attachment.kind === "image").length;
}, 0);
const memoryInstruction = await buildUserMemoryPrompt(msg.from?.id);
return {
turns,
imageCount,
systemInstruction: buildSystemInstruction(config, responseLanguage, includePythonToolPrompt, runtimeTarget.systemPromptAdditions, memoryInstruction),
};
}
export function serializeConversationSnapshot(
snapshot: ConversationSnapshot,
provider: AiProvider,
includeNames: boolean,
): { chatMessages: Array<OpenAIChatMessage | MistralChatMessage | OllamaChatMessage>; imageCount: number } {
switch (provider) {
case AiProvider.OPENAI: {
const messages = snapshot.turns.map(turn => buildOpenAiMessage(turn, provider, includeNames));
if (snapshot.systemInstruction) {
messages.unshift({role: "system", content: snapshot.systemInstruction, type: "message"});
}
return {chatMessages: messages, imageCount: snapshot.imageCount};
}
case AiProvider.MISTRAL: {
const messages = snapshot.turns.map(turn => buildMistralMessage(turn, provider, includeNames));
if (snapshot.systemInstruction) {
messages.unshift({role: "system", content: snapshot.systemInstruction});
}
return {chatMessages: messages, imageCount: snapshot.imageCount};
}
case AiProvider.OLLAMA: {
const messages = snapshot.turns.map(turn => buildOllamaMessage(turn, provider, includeNames));
if (snapshot.systemInstruction) {
messages.unshift({role: "system", content: snapshot.systemInstruction});
}
return {chatMessages: messages, imageCount: snapshot.imageCount};
}
}
return {chatMessages: [], imageCount: snapshot.imageCount};
}
-102
View File
@@ -1,102 +0,0 @@
import {AiProvider} from "../model/ai-provider";
import {AiDownloadedFile} from "./telegram-attachments";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {deleteMistralLibrary, RuntimeConfigSnapshot, MistralDocumentReference, prepareMistralDocuments} from "./unified-ai-runner.shared";
import {MistralChatMessage} from "./mistral-chat-message";
import {OllamaChatMessage} from "./ollama-chat-message";
import {prepareOllamaDocumentRag} from "./ollama-rag";
import type {OllamaRagArtifactDetails} from "./ollama-rag";
import {OpenAIChatMessage} from "./openai-chat-message";
import {createOpenAiClient, createOllamaClient} from "./ai-runtime-target";
import {prepareOpenAiDocumentRag} from "./unified-ai-runner.openai";
export type PreparedDocumentRag =
| {
provider: AiProvider.OPENAI;
vectorStoreIds: string[];
uploadedFileIds: string[];
cleanup: () => Promise<void>;
}
| {
provider: AiProvider.MISTRAL;
documents: MistralDocumentReference[];
libraryId?: string;
cleanup: () => Promise<void>;
}
| {
provider: AiProvider.OLLAMA;
prepared: boolean;
artifact?: OllamaRagArtifactDetails;
cleanup: () => Promise<void>;
};
export async function prepareDocumentRag(
provider: AiProvider,
downloads: AiDownloadedFile[],
messages: Array<OpenAIChatMessage | MistralChatMessage | OllamaChatMessage>,
streamMessage: TelegramStreamMessage,
config: RuntimeConfigSnapshot,
signal: AbortSignal,
userQuery: string,
): Promise<PreparedDocumentRag | undefined> {
const documents = downloads.filter(download => download.kind === "document");
if (!documents.length) return undefined;
if (provider === AiProvider.OPENAI && config.openAiBackend === "compatible") {
return undefined;
}
switch (provider) {
case AiProvider.OPENAI: {
const openAi = createOpenAiClient(config.openAiChatTarget);
const prepared = await prepareOpenAiDocumentRag(openAi, documents);
if (!prepared) {
throw new Error("OpenAI document RAG preparation returned no context.");
}
return {
provider,
vectorStoreIds: prepared.vectorStoreIds,
uploadedFileIds: prepared.uploadedFileIds,
cleanup: prepared.cleanup,
};
}
case AiProvider.MISTRAL: {
const prepared = await prepareMistralDocuments(documents, messages as MistralChatMessage[], streamMessage, config.mistralChatTarget, signal);
return {
provider,
documents: prepared.documents,
libraryId: prepared.libraryId,
cleanup: async () => {
await deleteMistralLibrary(prepared.libraryId, config.mistralChatTarget);
},
};
}
case AiProvider.OLLAMA: {
const prepared = await prepareOllamaDocumentRag({
downloads,
messages: messages as OllamaChatMessage[],
userQuery,
message: streamMessage,
config: {
embeddingModel: config.ollamaDocumentsTarget.model,
embeddingClient: createOllamaClient(config.ollamaDocumentsTarget),
chunkSize: config.ollamaRagChunkSize,
chunkOverlap: config.ollamaRagChunkOverlap,
topK: config.ollamaRagTopK,
maxContextChars: config.ollamaRagMaxContextChars,
minScore: config.ollamaRagMinScore,
maxArchiveFiles: config.ollamaRagMaxArchiveFiles,
maxArchiveBytes: config.ollamaRagMaxArchiveBytes,
maxArchiveDepth: config.ollamaRagMaxArchiveDepth,
},
});
return {
provider,
prepared: prepared.prepared,
artifact: prepared.artifact,
cleanup: async () => undefined,
};
}
}
}
-58
View File
@@ -1,58 +0,0 @@
import {AiProvider} from "../model/ai-provider";
import type {StoredAttachment} from "../model/stored-attachment";
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
export async function persistFinalTextArtifactAttachment(params: {
provider: AiProvider;
model: string;
text: string;
chatId: number;
messageId: number;
}): Promise<StoredAttachment | undefined> {
const text = params.text.trim();
if (!text) return Promise.resolve(undefined);
return await persistInternalJsonArtifactAttachment({
artifactKind: "final_text",
fileNamePrefix: "final-text",
chatId: params.chatId,
messageId: params.messageId,
payload: {
provider: params.provider,
model: params.model,
text,
},
metadata: {
provider: params.provider,
model: params.model,
textChars: text.length,
},
});
}
export async function persistErrorArtifactAttachment(params: {
provider: AiProvider;
model: string;
message: string;
recoverable: boolean;
chatId: number;
messageId: number;
}): Promise<StoredAttachment> {
return await persistInternalJsonArtifactAttachment({
artifactKind: "error",
fileNamePrefix: "error",
chatId: params.chatId,
messageId: params.messageId,
payload: {
provider: params.provider,
model: params.model,
message: params.message,
recoverable: params.recoverable,
},
metadata: {
provider: params.provider,
model: params.model,
recoverable: params.recoverable,
},
});
}
+84
View File
@@ -0,0 +1,84 @@
export type GeminiUserInputStep = {
type: "user_input";
content?: Array<GeminiContent>;
}
export type GeminiModelOutputStep = {
type: "model_output";
content?: Array<GeminiContent>;
}
export type GeminiFunctionCallStep = {
id: string;
arguments: {
[key: string]: unknown;
};
name: string;
type: "function_call";
signature?: string;
}
export type GeminiFunctionResultStep = {
call_id: string;
result: unknown | Array<GeminiTextContent | GeminiImageContent> | string;
type: "function_result";
is_error?: boolean;
name?: string;
signature?: string;
}
export type GeminiStep =
| GeminiUserInputStep
| GeminiModelOutputStep
| GeminiFunctionCallStep
| GeminiFunctionResultStep;
export type GeminiTextContent = {
text: string;
}
export type GeminiInlineContent = {
inlineData: {
data: string;
mimeType: string;
};
}
export type GeminiImageContent = GeminiInlineContent;
export type GeminiAudioContent = GeminiInlineContent;
export type GeminiDocumentContent = GeminiInlineContent;
export type GeminiVideoContent = GeminiInlineContent;
export type GeminiFunctionCallContent = {
functionCall: {
id?: string;
name?: string;
args?: Record<string, unknown>;
};
}
export type GeminiFunctionResponseContent = {
functionResponse: {
id?: string;
name?: string;
response: Record<string, unknown>;
};
}
export type GeminiContent =
| GeminiTextContent
| GeminiInlineContent
| GeminiFunctionCallContent
| GeminiFunctionResponseContent;
export type GeminiTurn = {
content?: Array<GeminiContent> | GeminiContent;
role?: string;
}
export type GeminiInput = string | Array<GeminiStep> | Array<GeminiContent> | GeminiContent;
export type GeminiMessage = {
role: "user" | "model";
parts: GeminiContent[];
};
-102
View File
@@ -1,102 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import {createHash} from "node:crypto";
import {Environment} from "../common/environment";
import {ArtifactStore} from "../common/artifact-store";
import type {StoredAttachment} from "../model/stored-attachment";
import {PIPELINE_ATTACHMENT_LIMIT_BYTES, type PipelineArtifactKind} from "./user-request-pipeline";
export type InternalArtifactAttachmentInput = {
artifactKind: PipelineArtifactKind;
fileNamePrefix: string;
chatId: number;
messageId: number;
requestId?: string;
payload: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
const INTERNAL_ARTIFACT_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
function sha256(buffer: Buffer): string {
return createHash("sha256").update(buffer).digest("hex");
}
function safeFileNamePart(value: string): string {
return value.replace(/[\\/:*?"<>|\u0000-\u001F]/g, "_").slice(0, 80) || "artifact";
}
export async function persistInternalJsonArtifactAttachment(input: InternalArtifactAttachmentInput): Promise<StoredAttachment> {
const createdAt = new Date().toISOString();
const buffer = Buffer.from(JSON.stringify({
artifactKind: input.artifactKind,
createdAt,
...input.payload,
}, null, 2), "utf8");
if (buffer.length > PIPELINE_ATTACHMENT_LIMIT_BYTES) {
throw new Error(`Internal ${input.artifactKind} artifact is larger than ${PIPELINE_ATTACHMENT_LIMIT_BYTES} bytes.`);
}
const dir = path.join(Environment.DATA_PATH, "cache", "internal-artifacts", input.artifactKind);
fs.mkdirSync(dir, {recursive: true});
const digest = sha256(buffer);
const fileName = `${safeFileNamePart(input.fileNamePrefix)}-${input.chatId}-${input.messageId}-${Date.now()}.json`;
const cachePath = path.join(dir, fileName);
fs.writeFileSync(cachePath, buffer);
const attachment: StoredAttachment = {
kind: "document",
fileId: cachePath,
fileUniqueId: digest,
fileName,
mimeType: "application/json",
cachePath,
sizeBytes: buffer.length,
sha256: digest,
scope: "internal_artifact",
artifactKind: input.artifactKind,
metadata: input.metadata,
};
await ArtifactStore.put({
id: "",
requestId: input.requestId ?? `message:${input.chatId}:${input.messageId}:${input.artifactKind}`,
messageChatId: input.chatId,
messageId: input.messageId,
kind: input.artifactKind,
stage: input.artifactKind,
attachmentId: cachePath,
payload: {
artifactKind: input.artifactKind,
createdAt,
...input.payload,
},
createdAt,
attachment,
});
return attachment;
}
export function cleanupInternalArtifactCache(now = Date.now()): void {
const root = path.join(Environment.DATA_PATH, "cache", "internal-artifacts");
if (!fs.existsSync(root)) return;
const cutoff = now - INTERNAL_ARTIFACT_RETENTION_MS;
for (const artifactKind of fs.readdirSync(root, {withFileTypes: true})) {
if (!artifactKind.isDirectory()) continue;
const dir = path.join(root, artifactKind.name);
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
if (!entry.isFile()) continue;
const filePath = path.join(dir, entry.name);
const stat = fs.statSync(filePath);
if (stat.mtimeMs < cutoff) {
fs.rmSync(filePath, {force: true});
}
}
}
}
-421
View File
@@ -1,421 +0,0 @@
import {spawn, type ChildProcessWithoutNullStreams} from "node:child_process";
import type {BoundaryValue} from "../../common/boundary-types.js";
import {toolsLogger} from "../tools/tool-logger.js";
import type {McpServerConfig} from "./mcp-config.js";
const logger = toolsLogger.child("mcp");
const MCP_PROTOCOL_VERSION = "2025-06-18";
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
export type McpToolDefinition = {
name: string;
description?: string;
inputSchema?: BoundaryValue;
};
type JsonRpcRequest = {
jsonrpc: "2.0";
id: number;
method: string;
params?: BoundaryValue;
};
type JsonRpcNotification = {
jsonrpc: "2.0";
method: string;
params?: BoundaryValue;
};
type JsonRpcResponse = {
jsonrpc?: "2.0";
id?: BoundaryValue;
result?: BoundaryValue;
error?: {
code?: number;
message?: string;
data?: BoundaryValue;
};
};
interface JsonRpcTransport {
request(method: string, params?: BoundaryValue): Promise<BoundaryValue>;
notify(method: string, params?: BoundaryValue): Promise<void>;
close(): Promise<void>;
}
function isRecord(value: BoundaryValue): value is Record<string, BoundaryValue> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function toJsonRpcResponse(value: BoundaryValue): JsonRpcResponse | undefined {
if (!isRecord(value)) return undefined;
if (value.jsonrpc !== undefined && value.jsonrpc !== "2.0") return undefined;
return value as JsonRpcResponse;
}
function extractJsonRpcResult(response: BoundaryValue, expectedId?: number): BoundaryValue {
const parsed = toJsonRpcResponse(response);
if (!parsed) {
throw new Error("Invalid JSON-RPC response from MCP server.");
}
if (parsed.error) {
throw new Error(parsed.error.message || "MCP server returned an error.");
}
if (expectedId !== undefined && parsed.id !== undefined && parsed.id !== expectedId) {
throw new Error(`Unexpected JSON-RPC response id from MCP server. Expected ${expectedId}, got ${String(parsed.id)}.`);
}
return parsed.result ?? {};
}
function parseSsePayload(text: string): BoundaryValue[] {
const events: string[] = [];
let current: string[] = [];
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trimEnd();
if (!line) {
if (current.length) {
events.push(current.join("\n"));
current = [];
}
continue;
}
if (line.startsWith("data:")) {
current.push(line.slice(5).replace(/^ /, ""));
}
}
if (current.length) {
events.push(current.join("\n"));
}
return events.map(event => {
try {
return JSON.parse(event) as BoundaryValue;
} catch {
return undefined;
}
}).filter((event): event is BoundaryValue => event !== undefined);
}
function timeoutPromise<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
let timeoutId: NodeJS.Timeout | undefined;
const timeout = new Promise<T>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
if (timeoutId) clearTimeout(timeoutId);
});
}
class StdioJsonRpcTransport implements JsonRpcTransport {
private readonly process: ChildProcessWithoutNullStreams;
private readonly pending = new Map<number, {resolve: (value: BoundaryValue) => void; reject: (error: Error) => void;}>();
private buffer = "";
private nextId = 1;
constructor(private readonly config: McpServerConfig) {
if (!config.command) {
throw new Error(`MCP stdio server '${config.name}' is missing command.`);
}
this.process = spawn(config.command, config.args ?? [], {
cwd: config.cwd,
env: {
...process.env,
...config.env,
},
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
this.process.stdout.on("data", chunk => this.handleStdout(chunk));
this.process.stderr.on("data", chunk => {
const text = chunk.toString("utf8").trim();
if (text) logger.debug("stdio.stderr", {server: config.name, text});
});
this.process.on("error", error => this.failAll(error));
this.process.on("exit", code => this.failAll(new Error(`MCP stdio server '${config.name}' exited with code ${code ?? "unknown"}.`)));
}
private handleStdout(chunk: Buffer): void {
this.buffer += chunk.toString("utf8");
let newlineIndex = this.buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = this.buffer.slice(0, newlineIndex).trim();
this.buffer = this.buffer.slice(newlineIndex + 1);
newlineIndex = this.buffer.indexOf("\n");
if (!line) continue;
try {
const message = JSON.parse(line) as JsonRpcResponse | JsonRpcNotification;
if ("id" in message && message.id !== undefined) {
const pending = this.pending.get(Number(message.id));
if (pending) {
this.pending.delete(Number(message.id));
if ("error" in message && message.error) {
pending.reject(new Error(message.error.message || "MCP stdio request failed."));
} else {
pending.resolve((message as JsonRpcResponse).result ?? {});
}
}
continue;
}
if ("method" in message) {
logger.debug("stdio.notification", {server: this.config.name, method: message.method});
}
} catch (error) {
logger.warn("stdio.parse_failed", {
server: this.config.name,
line: line.slice(0, 500),
error: error instanceof Error ? error.message : String(error),
});
}
}
}
private failAll(error: Error): void {
for (const pending of this.pending.values()) {
pending.reject(error);
}
this.pending.clear();
}
async request(method: string, params?: BoundaryValue): Promise<BoundaryValue> {
if (this.process.exitCode !== null) {
throw new Error(`MCP stdio server '${this.config.name}' is not running.`);
}
const id = this.nextId++;
const request: JsonRpcRequest = {
jsonrpc: "2.0",
id,
method,
params,
};
const result = new Promise<BoundaryValue>((resolve, reject) => {
this.pending.set(id, {resolve, reject});
});
this.process.stdin.write(`${JSON.stringify(request)}\n`);
return timeoutPromise(result, this.config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, `${this.config.name}.${method}`);
}
async notify(method: string, params?: BoundaryValue): Promise<void> {
if (this.process.exitCode !== null) {
throw new Error(`MCP stdio server '${this.config.name}' is not running.`);
}
const notification: JsonRpcNotification = {
jsonrpc: "2.0",
method,
params,
};
this.process.stdin.write(`${JSON.stringify(notification)}\n`);
}
async close(): Promise<void> {
this.failAll(new Error(`MCP stdio server '${this.config.name}' closed.`));
if (!this.process.killed) {
this.process.kill();
}
}
}
class HttpJsonRpcTransport implements JsonRpcTransport {
private nextId = 1;
private sessionId?: string;
constructor(private readonly config: McpServerConfig) {
if (!config.url) {
throw new Error(`MCP HTTP server '${config.name}' is missing url.`);
}
}
private async post(body: BoundaryValue): Promise<Response> {
const controller = new AbortController();
const timeoutMs = this.config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(this.config.url!, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
...(this.sessionId ? {"Mcp-Session-Id": this.sessionId} : {}),
...(this.config.headers ?? {}),
},
body: JSON.stringify(body),
signal: controller.signal,
}).finally(() => clearTimeout(timeoutId));
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
async request(method: string, params?: BoundaryValue): Promise<BoundaryValue> {
const id = this.nextId++;
const request: JsonRpcRequest = {
jsonrpc: "2.0",
id,
method,
params,
};
const response = await this.post(request);
const sessionId = response.headers.get("Mcp-Session-Id");
if (sessionId) {
this.sessionId = sessionId;
}
if (!response.ok) {
const errorText = await response.text().catch(() => "");
throw new Error(`MCP HTTP server '${this.config.name}' returned ${response.status}: ${errorText || response.statusText}`);
}
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
let payload: BoundaryValue;
if (contentType.includes("text/event-stream")) {
const text = await response.text();
const messages = parseSsePayload(text);
const responseMessage = messages.map(toJsonRpcResponse).find(message => message?.id === id && (message.result !== undefined || message.error));
payload = extractJsonRpcResult(responseMessage ?? messages[0] ?? {}, id);
} else {
payload = extractJsonRpcResult(await response.json() as BoundaryValue, id);
}
return payload;
}
async notify(method: string, params?: BoundaryValue): Promise<void> {
const response = await this.post({
jsonrpc: "2.0",
method,
params,
});
const sessionId = response.headers.get("Mcp-Session-Id");
if (sessionId) {
this.sessionId = sessionId;
}
if (!response.ok && response.status !== 202) {
const errorText = await response.text().catch(() => "");
throw new Error(`MCP HTTP notification failed for '${this.config.name}' with ${response.status}: ${errorText || response.statusText}`);
}
}
async close(): Promise<void> {
return;
}
}
function createTransport(config: McpServerConfig): JsonRpcTransport {
return config.transport === "stdio"
? new StdioJsonRpcTransport(config)
: new HttpJsonRpcTransport(config);
}
function normalizeToolResultContent(content: BoundaryValue): string {
if (content === undefined || content === null) return "";
if (typeof content === "string") return content;
if (typeof content === "number" || typeof content === "boolean") return String(content);
if (Array.isArray(content)) return content.map(item => normalizeToolResultContent(item)).filter(Boolean).join("\n");
if (!isRecord(content)) return JSON.stringify(content);
if (content.type === "text" && typeof content.text === "string") return content.text;
if (content.type === "image") {
return `[image ${typeof content.mimeType === "string" ? content.mimeType : "unknown"}]`;
}
if (content.type === "resource" && isRecord(content.resource)) {
if (typeof content.resource.text === "string") return content.resource.text;
return JSON.stringify(content.resource);
}
return JSON.stringify(content);
}
export class McpClient {
private readonly transport: JsonRpcTransport;
private initialized = false;
constructor(readonly config: McpServerConfig) {
this.transport = createTransport(config);
}
async initialize(): Promise<void> {
if (this.initialized) return;
await this.transport.request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
clientInfo: {
name: "tg-chat-bot",
version: "1.0.0",
},
capabilities: {},
});
await this.transport.notify("notifications/initialized");
this.initialized = true;
}
async listTools(): Promise<McpToolDefinition[]> {
await this.initialize();
const result = await this.transport.request("tools/list");
if (!isRecord(result)) return [];
const tools = Array.isArray(result.tools) ? result.tools : [];
return tools.flatMap(tool => {
if (!isRecord(tool) || typeof tool.name !== "string") return [];
return [{
name: tool.name,
description: typeof tool.description === "string" ? tool.description : undefined,
inputSchema: tool.inputSchema,
}];
});
}
async callTool(name: string, args?: BoundaryValue): Promise<string> {
await this.initialize();
const result = await this.transport.request("tools/call", {
name,
arguments: args ?? {},
});
if (!isRecord(result)) {
return normalizeToolResultContent(result);
}
const content = Array.isArray(result.content) ? result.content : [];
const text = content.map(item => normalizeToolResultContent(item)).filter(Boolean).join("\n");
if (result.isError) {
return text ? `[MCP error] ${text}` : "[MCP error]";
}
return text || JSON.stringify(result);
}
async close(): Promise<void> {
await this.transport.close();
}
}
-106
View File
@@ -1,106 +0,0 @@
import type {BoundaryValue} from "../../common/boundary-types.js";
export type McpTransport = "stdio" | "http";
export type McpServerConfig = {
name: string;
transport: McpTransport;
command?: string;
args?: string[];
cwd?: string;
env?: Record<string, string>;
url?: string;
headers?: Record<string, string>;
timeoutMs?: number;
};
function isRecord(value: BoundaryValue): value is Record<string, BoundaryValue> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function asString(value: BoundaryValue): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function toStringRecord(value: BoundaryValue): Record<string, string> | undefined {
if (!isRecord(value)) return undefined;
const result: Record<string, string> = {};
for (const [key, entry] of Object.entries(value)) {
if (typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean") {
result[key] = String(entry);
}
}
return Object.keys(result).length ? result : undefined;
}
function toStringArray(value: BoundaryValue): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const items = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
.map(item => item.trim());
return items.length ? items : undefined;
}
function toPositiveInt(value: BoundaryValue): number | undefined {
const n = typeof value === "number"
? value
: typeof value === "string"
? Number(value)
: NaN;
if (!Number.isFinite(n) || n <= 0) return undefined;
return Math.floor(n);
}
function normalizeServerConfig(value: BoundaryValue, fallbackName?: string): McpServerConfig | undefined {
if (!isRecord(value)) return undefined;
const name = asString(value.name) ?? fallbackName;
const transportRaw = asString(value.transport);
const transport = transportRaw === "http" || transportRaw === "stdio" ? transportRaw : undefined;
if (!name || !transport) return undefined;
return {
name,
transport,
command: asString(value.command),
args: toStringArray(value.args),
cwd: asString(value.cwd),
env: toStringRecord(value.env),
url: asString(value.url),
headers: toStringRecord(value.headers),
timeoutMs: toPositiveInt(value.timeoutMs),
};
}
export function parseMcpServerConfigs(raw: string | undefined): McpServerConfig[] {
if (!raw?.trim()) return [];
let parsed: BoundaryValue;
try {
parsed = JSON.parse(raw) as BoundaryValue;
} catch (error) {
throw new Error(`Invalid MCP_SERVERS JSON: ${error instanceof Error ? error.message : String(error)}`);
}
if (Array.isArray(parsed)) {
return parsed.flatMap((item, index) => normalizeServerConfig(item, `server-${index + 1}`) ? [normalizeServerConfig(item, `server-${index + 1}`)!] : []);
}
if (!isRecord(parsed)) {
return [];
}
if (Array.isArray(parsed.servers)) {
return parsed.servers.flatMap((item, index) => normalizeServerConfig(item, `server-${index + 1}`) ? [normalizeServerConfig(item, `server-${index + 1}`)!] : []);
}
if (isRecord(parsed.mcpServers)) {
return Object.entries(parsed.mcpServers).flatMap(([name, item]) => normalizeServerConfig(item, name) ? [normalizeServerConfig(item, name)!] : []);
}
const single = normalizeServerConfig(parsed);
return single ? [single] : [];
}
-123
View File
@@ -1,123 +0,0 @@
import type {AiJsonValue, AiToolParameters} from "../tool-types.js";
import type {BoundaryValue} from "../../common/boundary-types.js";
type JsonSchemaRecord = Record<string, BoundaryValue>;
function isRecord(value: BoundaryValue): value is JsonSchemaRecord {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function toAiJsonValue(value: BoundaryValue): AiJsonValue | undefined {
if (value === undefined) return undefined;
if (value === null) return null;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
if (Array.isArray(value)) {
return value.map(item => toAiJsonValue(item) ?? null);
}
if (!isRecord(value)) return undefined;
const result: Record<string, AiJsonValue> = {};
for (const [key, entry] of Object.entries(value)) {
const normalized = toAiJsonValue(entry);
if (normalized !== undefined) {
result[key] = normalized;
}
}
return result;
}
function normalizeType(value: BoundaryValue): AiToolParameters["type"] | undefined {
const candidates = Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: typeof value === "string"
? [value]
: [];
const prioritized = candidates.find(item => item !== "null") ?? candidates[0];
if (!prioritized) return undefined;
switch (prioritized) {
case "object":
case "string":
case "number":
case "integer":
case "boolean":
case "array":
return prioritized;
default:
return undefined;
}
}
export function convertJsonSchemaToToolParameters(schema: BoundaryValue): AiToolParameters | undefined {
if (!isRecord(schema)) return undefined;
const declaredType = normalizeType(schema.type);
const inferredType = declaredType
?? (schema.properties !== undefined || schema.additionalProperties !== undefined ? "object" : undefined)
?? (schema.items !== undefined ? "array" : undefined)
?? "object";
const result: AiToolParameters = {
type: inferredType,
};
const description = typeof schema.description === "string" && schema.description.trim().length > 0
? schema.description.trim()
: undefined;
if (description) result.description = description;
const defaultValue = toAiJsonValue(schema.default);
if (defaultValue !== undefined) result.default = defaultValue;
if (Array.isArray(schema.enum)) {
const enumValues = schema.enum
.filter((item): item is string => typeof item === "string" && item.length > 0);
if (enumValues.length) result.enum = enumValues;
}
if (typeof schema.minItems === "number") result.minItems = schema.minItems;
if (typeof schema.maxItems === "number") result.maxItems = schema.maxItems;
if (typeof schema.minimum === "number") result.minimum = schema.minimum;
if (typeof schema.maximum === "number") result.maximum = schema.maximum;
if (Array.isArray(schema.required)) {
const required = schema.required.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
if (required.length) result.required = required;
}
if (inferredType === "object" || schema.properties !== undefined || schema.additionalProperties !== undefined) {
if (isRecord(schema.properties)) {
const properties: Record<string, AiToolParameters> = {};
for (const [key, value] of Object.entries(schema.properties)) {
const converted = convertJsonSchemaToToolParameters(value);
if (converted) properties[key] = converted;
}
if (Object.keys(properties).length) result.properties = properties;
}
if (schema.additionalProperties !== undefined) {
result.additionalProperties = typeof schema.additionalProperties === "boolean"
? schema.additionalProperties
: convertJsonSchemaToToolParameters(schema.additionalProperties);
}
}
if (inferredType === "array" || schema.items !== undefined) {
if (Array.isArray(schema.items)) {
const firstItem = schema.items[0];
if (firstItem !== undefined) {
const converted = convertJsonSchemaToToolParameters(firstItem);
if (converted) result.items = converted;
}
} else {
const converted = convertJsonSchemaToToolParameters(schema.items);
if (converted) result.items = converted;
}
}
return result;
}
-165
View File
@@ -1,165 +0,0 @@
import {Environment} from "../../common/environment.js";
import type {AiTool} from "../tool-types.js";
import type {ToolHandler} from "../tools/types.js";
import {normalizeToolArguments} from "../tools/utils.js";
import {toolsLogger} from "../tools/tool-logger.js";
import {convertJsonSchemaToToolParameters} from "./mcp-json-schema.js";
import {McpClient, type McpToolDefinition} from "./mcp-client.js";
import {parseMcpServerConfigs, type McpServerConfig} from "./mcp-config.js";
const logger = toolsLogger.child("mcp-registry");
type McpToolBinding = {
server: McpServerConfig;
client: McpClient;
remoteToolName: string;
localToolName: string;
tool: AiTool;
};
type McpInitSummary = {
servers: number;
loadedServers: number;
tools: number;
failedServers: string[];
};
const toolBindings = new Map<string, McpToolBinding>();
const clients = new Map<string, McpClient>();
let initPromise: Promise<McpInitSummary> | undefined;
function sanitizeSegment(value: string): string {
return value
.trim()
.replace(/[^a-zA-Z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "") || "tool";
}
function buildLocalToolName(serverName: string, toolName: string): string {
return `mcp__${sanitizeSegment(serverName)}__${sanitizeSegment(toolName)}`;
}
function buildTool(serverName: string, tool: McpToolDefinition): AiTool {
const localName = buildLocalToolName(serverName, tool.name);
const description = tool.description?.trim()
? `[MCP ${serverName}] ${tool.description.trim()}`
: `[MCP ${serverName}] ${tool.name}`;
return {
type: "function",
function: {
name: localName,
description,
parameters: convertJsonSchemaToToolParameters(tool.inputSchema),
},
};
}
async function loadServer(config: McpServerConfig): Promise<{loaded: boolean; tools: number}> {
const client = new McpClient(config);
clients.set(config.name, client);
try {
const remoteTools = await client.listTools();
let loaded = 0;
for (const remoteTool of remoteTools) {
const localName = buildLocalToolName(config.name, remoteTool.name);
if (toolBindings.has(localName)) {
logger.warn("tool.duplicate", {
server: config.name,
tool: remoteTool.name,
localName,
});
continue;
}
const binding: McpToolBinding = {
server: config,
client,
remoteToolName: remoteTool.name,
localToolName: localName,
tool: buildTool(config.name, remoteTool),
};
toolBindings.set(localName, binding);
loaded += 1;
}
logger.info("server.loaded", {
server: config.name,
transport: config.transport,
tools: loaded,
});
return {loaded: true, tools: loaded};
} catch (error) {
logger.error("server.failed", {
server: config.name,
transport: config.transport,
error: error instanceof Error ? error.message : String(error),
});
await client.close().catch(() => undefined);
clients.delete(config.name);
return {loaded: false, tools: 0};
}
}
export async function initializeMcpTools(): Promise<McpInitSummary> {
if (initPromise) return initPromise;
initPromise = (async () => {
toolBindings.clear();
await Promise.all([...clients.values()].map(client => client.close().catch(() => undefined)));
clients.clear();
const configs = parseMcpServerConfigs(Environment.MCP_SERVERS);
const results = await Promise.all(configs.map(config => loadServer(config)));
return {
servers: configs.length,
loadedServers: results.filter(result => result.loaded).length,
tools: [...results].reduce((sum, result) => sum + result.tools, 0),
failedServers: configs.filter((_, index) => !results[index]?.loaded).map(config => config.name),
};
})();
try {
const summary = await initPromise;
logger.info("init.done", summary);
return summary;
} catch (error) {
initPromise = undefined;
logger.error("init.failed", {error: error instanceof Error ? error.message : String(error)});
throw error;
}
}
export function getMcpTools(): AiTool[] {
return [...toolBindings.values()].map(binding => binding.tool);
}
export function getMcpToolHandlers(): Record<string, ToolHandler> {
const handlers: Record<string, ToolHandler> = {};
for (const binding of toolBindings.values()) {
handlers[binding.localToolName] = async args => {
const normalized = normalizeToolArguments(args, undefined);
return binding.client.callTool(binding.remoteToolName, normalized);
};
}
return handlers;
}
export function getMcpToolPrompts(_toolNames: string[]): string[] {
return [];
}
export async function shutdownMcpTools(): Promise<void> {
initPromise = undefined;
toolBindings.clear();
await Promise.all([...clients.values()].map(client => client.close().catch(() => undefined)));
clients.clear();
}
+1 -2
View File
@@ -67,7 +67,7 @@ export type MistralContentChunk =
export type MistralFunctionCall = { export type MistralFunctionCall = {
name: string; name: string;
arguments: AiJsonObject | string; arguments: Record<string, unknown> | string;
}; };
export type MistralToolCall = { export type MistralToolCall = {
@@ -110,4 +110,3 @@ export type MistralChatMessage =
| MistralSystemMessage | MistralSystemMessage
| MistralToolMessage | MistralToolMessage
| MistralUserMessage | MistralUserMessage
import {AiJsonObject} from "./tool-types";
-5
View File
@@ -1,5 +0,0 @@
export async function runSingleModelRequest<T>(params: {
execute: () => Promise<T>;
}): Promise<T> {
return await params.execute();
}
+9 -88
View File
@@ -476,40 +476,6 @@ type ExtractedRagDocument = {
text: string; text: string;
}; };
export type OllamaRagArtifactDetails = {
query: string;
extractedDocuments: Array<{
documentIndex: number;
fileName: string;
textChars: number;
}>;
selectedChunks: Array<{
sourceId: string;
documentIndex: number;
documentName: string;
chunkIndex: number;
chunkCount: number;
textChars: number;
score?: number;
}>;
skippedDocuments: Array<{
documentIndex: number;
fileName: string;
reason: string;
}>;
providerState: {
embeddingModel: string;
topK: number;
chunkSize: number;
chunkOverlap: number;
maxContextChars: number;
minScore: number;
maxArchiveFiles: number;
maxArchiveBytes: number;
maxArchiveDepth: number;
};
};
type ArchiveSkippedDocument = { type ArchiveSkippedDocument = {
fileName: string; fileName: string;
reason: string; reason: string;
@@ -621,7 +587,7 @@ function reserveArchiveFile(
return true; return true;
} }
function pushArchiveSkip(state: ArchiveExtractionState, fileName: string, reason: Error | string | object | null | undefined): void { function pushArchiveSkip(state: ArchiveExtractionState, fileName: string, reason: unknown): void {
state.skipped.push({ state.skipped.push({
fileName, fileName,
reason: reason instanceof Error ? reason.message : String(reason), reason: reason instanceof Error ? reason.message : String(reason),
@@ -641,7 +607,7 @@ function extractArchiveChildDocuments(
try { try {
return extractRagDocumentsFromFile(child, config, state, depth + 1); return extractRagDocumentsFromFile(child, config, state, depth + 1);
} catch (e) { } catch (e) {
pushArchiveSkip(state, child.fileName, e instanceof Error ? e : String(e)); pushArchiveSkip(state, child.fileName, e);
return []; return [];
} }
} }
@@ -675,7 +641,7 @@ function extractZipArchiveDocuments(
const buffer = readZipEntry(doc.buffer, entry); const buffer = readZipEntry(doc.buffer, entry);
documents.push(...extractArchiveChildDocuments(doc, normalizedName, buffer, config, state, depth)); documents.push(...extractArchiveChildDocuments(doc, normalizedName, buffer, config, state, depth));
} catch (e) { } catch (e) {
pushArchiveSkip(state, displayName, e instanceof Error ? e : String(e)); pushArchiveSkip(state, displayName, e);
} }
} }
@@ -1269,48 +1235,6 @@ function formatRagContext(chunks: DocumentChunk[], totalChunks: number, document
].filter(line => line.length > 0).join("\n"); ].filter(line => line.length > 0).join("\n");
} }
function buildOllamaRagArtifactDetails(
query: string,
documents: SourceDocument[],
selected: DocumentChunk[],
skippedDocuments: SkippedDocument[],
config: OllamaDocumentRagConfig,
): OllamaRagArtifactDetails {
return {
query,
extractedDocuments: documents.map(document => ({
documentIndex: document.documentIndex,
fileName: document.fileName,
textChars: document.text.length,
})),
selectedChunks: selected.map(chunk => ({
sourceId: chunk.sourceId,
documentIndex: chunk.documentIndex,
documentName: chunk.documentName,
chunkIndex: chunk.chunkIndex,
chunkCount: chunk.chunkCount,
textChars: chunk.text.length,
score: chunk.score,
})),
skippedDocuments: skippedDocuments.map(document => ({
documentIndex: document.documentIndex,
fileName: document.fileName,
reason: document.reason,
})),
providerState: {
embeddingModel: config.embeddingModel,
topK: config.topK,
chunkSize: config.chunkSize,
chunkOverlap: config.chunkOverlap,
maxContextChars: config.maxContextChars,
minScore: config.minScore,
maxArchiveFiles: config.maxArchiveFiles,
maxArchiveBytes: config.maxArchiveBytes,
maxArchiveDepth: config.maxArchiveDepth,
},
};
}
function injectOllamaRagContext(messages: OllamaChatMessage[], context: string): void { function injectOllamaRagContext(messages: OllamaChatMessage[], context: string): void {
const systemIndex = messages.findIndex(message => message.role === "system"); const systemIndex = messages.findIndex(message => message.role === "system");
@@ -1334,7 +1258,7 @@ export async function buildOllamaDocumentRagContext(params: {
userQuery: string; userQuery: string;
config: OllamaDocumentRagConfig; config: OllamaDocumentRagConfig;
onStatus?: (status: string) => Promise<void> | void; onStatus?: (status: string) => Promise<void> | void;
}): Promise<{context: string; artifact: OllamaRagArtifactDetails} | null> { }): Promise<string | null> {
const docs = params.downloads.filter(download => download.kind === "document"); const docs = params.downloads.filter(download => download.kind === "document");
if (!docs.length) return null; if (!docs.length) return null;
@@ -1409,10 +1333,7 @@ export async function buildOllamaDocumentRagContext(params: {
throw new Error(Environment.localRagNoSuitableFragmentsText); throw new Error(Environment.localRagNoSuitableFragmentsText);
} }
return { return formatRagContext(selected, chunks.length, documents, skippedDocuments);
context: formatRagContext(selected, chunks.length, documents, skippedDocuments),
artifact: buildOllamaRagArtifactDetails(buildRetrievalQuery(params.userQuery, params.messages), documents, selected, skippedDocuments, params.config),
};
} }
export async function prepareOllamaDocumentRag(params: { export async function prepareOllamaDocumentRag(params: {
@@ -1421,7 +1342,7 @@ export async function prepareOllamaDocumentRag(params: {
userQuery: string; userQuery: string;
message: TelegramStreamMessage; message: TelegramStreamMessage;
config: OllamaDocumentRagConfig; config: OllamaDocumentRagConfig;
}): Promise<{prepared: boolean; artifact?: OllamaRagArtifactDetails}> { }): Promise<boolean> {
const context = await buildOllamaDocumentRagContext({ const context = await buildOllamaDocumentRagContext({
downloads: params.downloads, downloads: params.downloads,
messages: params.messages, messages: params.messages,
@@ -1433,7 +1354,7 @@ export async function prepareOllamaDocumentRag(params: {
}, },
}); });
if (!context) return {prepared: false}; if (!context) return false;
injectOllamaRagContext(params.messages, context.context); injectOllamaRagContext(params.messages, context);
return {prepared: true, artifact: context.artifact}; return true;
} }
-66
View File
@@ -1,66 +0,0 @@
import {isRecord} from "./unified-ai-runner.shared.js";
import type {OpenAIChatMessage, OpenAICompatibleChatMessage} from "./openai-chat-message.js";
import type {ToolCallData} from "./unified-ai-runner.shared.js";
export function responseContentToText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map(part => isRecord(part) && typeof part.text === "string" ? part.text : "")
.join("");
}
export function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAICompatibleChatMessage[] {
return messages.map((message): OpenAICompatibleChatMessage => {
if (message.role === "system") {
return {role: "system", content: responseContentToText(message.content)};
}
if (message.role === "assistant") {
const text = responseContentToText(message.content);
return text.length
? {role: "assistant", content: text}
: {role: "assistant", content: null};
}
const content = Array.isArray(message.content)
? (() => {
const parts = message.content.map((part): {type: "text"; text: string} | {type: "image_url"; image_url: {url: string}} => {
if (isRecord(part) && part.type === "input_image") {
return {
type: "image_url",
image_url: {url: String(part.image_url ?? "")},
};
}
return {
type: "text",
text: isRecord(part) && typeof part.text === "string" ? part.text : "",
};
});
return parts.every(part => part.type === "text")
? parts.map(part => part.text).join("")
: parts;
})()
: message.content;
return {role: "user", content};
});
}
export function buildAssistantToolMessage(calls: ToolCallData[], text: string): OpenAICompatibleChatMessage {
return {
role: "assistant",
content: text,
tool_calls: calls.map(call => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: call.argumentsText,
},
})),
};
}
+3 -18
View File
@@ -1,22 +1,7 @@
import type { import type {ResponseInputMessageContentList} from "openai/resources/responses/responses";
ResponseInputMessageContentList,
ResponseOutputMessage,
} from "openai/resources/responses/responses";
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
type OpenAIInputChatMessage = { export type OpenAIChatMessage = {
type: "message"; type: "message";
role: "system" | "user"; role: "system" | "user" | "assistant";
content: string | ResponseInputMessageContentList; content: string | ResponseInputMessageContentList;
}; };
type OpenAIOutputChatMessage = {
type: "message";
role: "assistant";
content: ResponseOutputMessage["content"];
phase?: ResponseOutputMessage["phase"];
} & Pick<ResponseOutputMessage, "id" | "status">;
export type OpenAIChatMessage = OpenAIInputChatMessage | OpenAIOutputChatMessage;
export type OpenAICompatibleChatMessage = ChatCompletionMessageParam;
-74
View File
@@ -1,74 +0,0 @@
import {Message} from "typescript-telegram-bot-api";
import fs from "node:fs";
import path from "node:path";
import {bot} from "../index.js";
import {Environment} from "../common/environment.js";
import {logError} from "../util/utils.js";
import {errorMessage} from "./unified-ai-runner.shared.js";
import {SendFileAttachmentResult, SendFileAttachmentResultSchema} from "./tools/files.js";
export async function tryToUploadFiles(
msg: Message,
toolResults: string[]
): Promise<
| { found: false }
| { found: true, uploaded: true }
| { found: boolean, uploaded: false, error: string, toolIndex: number }
> {
let sendFileAttachment: {
result: SendFileAttachmentResult & { success: true },
toolIndex: number
} | null = null;
let found = false;
try {
for (const [index, toolResult] of toolResults.entries()) {
const raw = JSON.parse(toolResult);
const res = SendFileAttachmentResultSchema.safeParse(raw);
if (res.success) {
found = true;
if (res.data.success) {
sendFileAttachment = {result: res.data, toolIndex: index};
}
}
}
if (!found) {
return {found: false};
}
const attachmentRoot = Environment.FILE_TOOLS_ROOT_DIR;
const attachmentPath = attachmentRoot
? path.join(
attachmentRoot,
String(msg.from?.id),
sendFileAttachment?.result?.attachment?.relativePath ?? "",
)
: "";
if (!fs.existsSync(attachmentPath)) {
throw new Error(`Attachment file does not exist: ${attachmentPath}`);
}
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(attachmentPath),
});
return {found: true, uploaded: true};
} catch (e) {
logError(e instanceof Error ? e : String(e));
return {
found: found,
uploaded: false,
error: errorMessage(e instanceof Error ? e : String(e)),
toolIndex: sendFileAttachment?.toolIndex ?? -1
};
}
}
-198
View File
@@ -1,198 +0,0 @@
import type {ToolCallData} from "./unified-ai-runner.shared.js";
import type {ResponseStreamEvent} from "openai/resources/responses/responses";
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function normalizeToolCallId(value: unknown, fallback: string): string {
return typeof value === "string" && value.trim().length > 0 ? value : fallback;
}
function normalizeToolArguments(value: unknown): string {
if (typeof value === "string") return value;
return JSON.stringify(value ?? {});
}
function normalizeToolArgumentsChunk(value: unknown): string {
if (typeof value === "string") return value;
if (value === undefined || value === null) return "";
return JSON.stringify(value);
}
export function extractOpenAiToolCalls(response: unknown): ToolCallData[] {
const output = isRecord(response) && Array.isArray(response.output) ? response.output : [];
return output
.filter(item => isRecord(item) && item.type === "function_call" && (typeof item.call_id === "string" || typeof item.name === "string"))
.map((item, index) => ({
id: normalizeToolCallId(item.call_id, `openai_${index}`),
name: typeof item.name === "string" ? item.name : "",
argumentsText: normalizeToolArguments(item.arguments),
}))
.filter(call => call.name.length > 0);
}
export function extractOpenAiTextDelta(input: unknown): string {
const event = input as ResponseStreamEvent | undefined;
return event?.type === "response.output_text.delta" ? event.delta ?? "" : "";
}
export function extractOpenAiChatTextDelta(input: unknown): string {
const event = isRecord(input) ? input : undefined;
const choice = event && Array.isArray(event.choices) && isRecord(event.choices[0]) ? event.choices[0] : undefined;
const delta = isRecord(choice?.delta) ? choice.delta : undefined;
const content = delta && typeof delta.content === "string" ? delta.content : "";
return content;
}
export function normalizeStreamingTextDelta(existingText: string, deltaText: string): string {
if (!deltaText) return "";
if (!existingText) return deltaText;
if (deltaText.startsWith(existingText)) {
return deltaText.slice(existingText.length);
}
return deltaText;
}
export function extractOpenAiChatToolCalls(response: unknown): ToolCallData[] {
const record = isRecord(response) ? response : undefined;
const choice = record && Array.isArray(record.choices) && isRecord(record.choices[0]) ? record.choices[0] : undefined;
const message = isRecord(choice?.message) ? choice.message : undefined;
const toolCalls = message && Array.isArray(message.tool_calls) ? message.tool_calls : [];
return toolCalls
.filter((item, index) => isRecord(item) && ((typeof item.id === "string") || typeof item.index === "number" || index >= 0))
.map((item, index) => {
const call = isRecord(item) ? item : {};
const fn = isRecord(call.function) ? call.function : undefined;
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
return {
id: normalizeToolCallId(call.id, `openai_chat_${typeof call.index === "number" ? call.index : index}`),
name,
argumentsText: normalizeToolArguments(fn?.arguments ?? call.arguments),
};
})
.filter(call => call.name.length > 0);
}
export function extractOpenAiChatStreamingToolCalls(input: unknown): ToolCallData[] {
const event = isRecord(input) ? input : undefined;
const choice = event && Array.isArray(event.choices) && isRecord(event.choices[0]) ? event.choices[0] : undefined;
const delta = isRecord(choice?.delta) ? choice.delta : undefined;
const toolCalls = Array.isArray(delta?.tool_calls) ? delta.tool_calls : [];
return toolCalls
.map((item, index) => {
const call = isRecord(item) ? item : {};
const fn = isRecord(call.function) ? call.function : undefined;
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
return {
id: normalizeToolCallId(call.id, `openai_chat_${typeof call.index === "number" ? call.index : index}`),
name,
argumentsText: normalizeToolArgumentsChunk(fn?.arguments ?? call.arguments),
};
})
.filter(call => call.id.length > 0);
}
export function mergeToolCallChunks(existing: ToolCallData[], chunks: ToolCallData[]): ToolCallData[] {
const merged = new Map<string, ToolCallData>(existing.map(call => [call.id, {...call}]));
for (const chunk of chunks) {
const current = merged.get(chunk.id);
if (!current) {
merged.set(chunk.id, {...chunk});
continue;
}
merged.set(chunk.id, {
id: current.id,
name: current.name || chunk.name,
argumentsText: current.argumentsText + (chunk.argumentsText ?? ""),
});
}
return [...merged.values()];
}
export function extractOpenAiStreamingToolCalls(input: unknown): ToolCallData[] {
const event = input as ResponseStreamEvent | undefined;
if (event?.type === "response.output_item.added" && isRecord(event.item) && event.item.type === "function_call") {
return extractOpenAiToolCalls({
output: [{
type: "function_call",
call_id: event.item.call_id ?? event.item.id,
name: event.item.name,
arguments: event.item.arguments,
}],
});
}
return [];
}
export function extractMistralToolCalls(calls: unknown): ToolCallData[] {
const normalized = Array.isArray(calls)
? calls
: isRecord(calls) && (Array.isArray(calls.toolCalls) || Array.isArray(calls.tool_calls))
? (calls.toolCalls ?? calls.tool_calls)
: [];
if (!Array.isArray(normalized)) return [];
return normalized
.map((item, index) => {
const call = isRecord(item) ? item : {};
const fn = isRecord(call.function) ? call.function : undefined;
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
return {
id: normalizeToolCallId(call.id, `mistral_${index}`),
name,
argumentsText: normalizeToolArguments(fn?.arguments ?? call.arguments),
};
})
.filter(call => call.name.length > 0);
}
export function extractMistralTextDelta(input: unknown): string {
const delta = isRecord(input) ? input : {};
const content = delta.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map(part => isRecord(part) && typeof part.text === "string" ? part.text : "")
.join("");
}
return "";
}
export function extractOllamaToolCalls(calls: unknown): ToolCallData[] {
const normalized = Array.isArray(calls)
? calls
: isRecord(calls) && Array.isArray(calls.tool_calls)
? calls.tool_calls
: [];
if (!Array.isArray(normalized)) return [];
return normalized
.map((item, index) => {
const call = isRecord(item) ? item : {};
const fn = isRecord(call.function) ? call.function : undefined;
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
return {
id: normalizeToolCallId(call.id, `ollama_${index}`),
name,
argumentsText: normalizeToolArguments(fn?.arguments ?? call.arguments),
};
})
.filter(call => call.name.length > 0);
}
export function extractOllamaTextDelta(input: unknown): string {
const chunk = isRecord(input) ? input.message : undefined;
return isRecord(chunk) && typeof chunk.content === "string" ? chunk.content : "";
}
-196
View File
@@ -1,196 +0,0 @@
import {AiProvider} from "../model/ai-provider.js";
import type {BoundaryValue} from "../common/boundary-types.js";
import type {RuntimeConfigSnapshot, ToolCallData} from "./unified-ai-runner.shared.js";
import {getMistralTools, getOllamaTools, getOpenAIResponsesTools, getOpenAICodeInterpreterTool} from "./tool-mappers.js";
import type {MistralChatMessage as MistralMessageType} from "./mistral-chat-message.js";
import type {OpenAIChatMessage as OpenAiMessageType} from "./openai-chat-message.js";
import type {Message as OllamaMessage} from "ollama";
import {
extractMistralTextDelta,
extractMistralToolCalls,
extractOllamaTextDelta,
extractOllamaToolCalls,
extractOpenAiTextDelta,
extractOpenAiStreamingToolCalls,
extractOpenAiToolCalls,
} from "./provider-adapter-contract.js";
export type ProviderRankToolOptions = {
forCreator?: boolean;
vectorStoreIds?: string[];
};
export interface AiProviderAdapter {
readonly provider: AiProvider;
mapMessages(messages: readonly unknown[]): unknown[];
rankTools(config: RuntimeConfigSnapshot, options?: ProviderRankToolOptions): readonly BoundaryValue[];
callModel<T>(request: unknown, execute: () => Promise<T>): Promise<T>;
extractTextDelta(input: unknown): string;
extractToolCalls(input: unknown): ToolCallData[];
extractStreamingToolCalls(input: unknown): ToolCallData[];
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void;
finalize(): Promise<void>;
}
function appendOllamaToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
content: results[index] ?? "",
tool_name: call.name,
});
}
}
class OpenAiProviderAdapter implements AiProviderAdapter {
readonly provider = AiProvider.OPENAI;
mapMessages(messages: readonly unknown[]): unknown[] {
return messages as OpenAiMessageType[];
}
rankTools(config: RuntimeConfigSnapshot, options?: ProviderRankToolOptions): readonly BoundaryValue[] {
const tools: BoundaryValue[] = [
...getOpenAIResponsesTools(options?.forCreator) as BoundaryValue[],
getOpenAICodeInterpreterTool() as BoundaryValue,
{
type: "image_generation",
model: config.openAiImageTarget.model,
size: "auto",
moderation: "low",
output_format: "png",
partial_images: 3,
},
{type: "web_search"},
];
if (options?.vectorStoreIds?.length) {
tools.unshift({
type: "file_search",
vector_store_ids: options.vectorStoreIds,
});
}
return tools;
}
async callModel<T>(_request: unknown, execute: () => Promise<T>): Promise<T> {
return execute();
}
extractTextDelta(input: unknown): string {
return extractOpenAiTextDelta(input);
}
extractToolCalls(input: unknown): ToolCallData[] {
return extractOpenAiToolCalls(input);
}
extractStreamingToolCalls(input: unknown): ToolCallData[] {
return extractOpenAiStreamingToolCalls(input);
}
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
for (const [index, call] of calls.entries()) {
messages.push({
type: "function_call_output",
call_id: call.id,
output: results[index] ?? "",
});
}
}
async finalize(): Promise<void> {
return;
}
}
class MistralProviderAdapter implements AiProviderAdapter {
readonly provider = AiProvider.MISTRAL;
mapMessages(messages: readonly unknown[]): unknown[] {
return messages as MistralMessageType[];
}
rankTools(_config: RuntimeConfigSnapshot, options?: ProviderRankToolOptions): readonly BoundaryValue[] {
return getMistralTools(options?.forCreator) as BoundaryValue[];
}
async callModel<T>(_request: unknown, execute: () => Promise<T>): Promise<T> {
return execute();
}
extractTextDelta(input: unknown): string {
return extractMistralTextDelta(input);
}
extractToolCalls(input: unknown): ToolCallData[] {
return extractMistralToolCalls(input);
}
extractStreamingToolCalls(input: unknown): ToolCallData[] {
return this.extractToolCalls(input);
}
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
name: call.name,
toolCallId: call.id,
content: results[index] ?? "",
});
}
}
async finalize(): Promise<void> {
return;
}
}
class OllamaProviderAdapter implements AiProviderAdapter {
readonly provider = AiProvider.OLLAMA;
mapMessages(messages: readonly unknown[]): unknown[] {
return messages as OllamaMessage[];
}
rankTools(_config: RuntimeConfigSnapshot, options?: ProviderRankToolOptions): readonly BoundaryValue[] {
return getOllamaTools(options?.forCreator) as BoundaryValue[];
}
async callModel<T>(_request: unknown, execute: () => Promise<T>): Promise<T> {
return execute();
}
extractTextDelta(input: unknown): string {
return extractOllamaTextDelta(input);
}
extractToolCalls(input: unknown): ToolCallData[] {
return extractOllamaToolCalls(input);
}
extractStreamingToolCalls(input: unknown): ToolCallData[] {
return this.extractToolCalls(input);
}
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
appendOllamaToolResults(messages, calls, results);
}
async finalize(): Promise<void> {
return;
}
}
export function getProviderAdapter(provider: AiProvider): AiProviderAdapter {
switch (provider) {
case AiProvider.OPENAI:
return new OpenAiProviderAdapter();
case AiProvider.MISTRAL:
return new MistralProviderAdapter();
case AiProvider.OLLAMA:
return new OllamaProviderAdapter();
}
}
-18
View File
@@ -1,18 +0,0 @@
import {AiProvider} from "../model/ai-provider";
const PROVIDER_ALIASES = new Map<string, AiProvider>([
["openai", AiProvider.OPENAI],
["chatgpt", AiProvider.OPENAI],
["gpt", AiProvider.OPENAI],
["mistral", AiProvider.MISTRAL],
["ollama", AiProvider.OLLAMA],
]);
export function parseProviderToken(token: string | undefined): AiProvider | undefined {
if (!token) return undefined;
return PROVIDER_ALIASES.get(token.toLowerCase().replace(/:$/, ""));
}
export function providerDisplayName(provider: AiProvider): string {
return provider.charAt(0) + provider.slice(1).toLowerCase();
}
+57 -8
View File
@@ -7,9 +7,12 @@ import {isOllamaSpeechToTextModel} from "./speech-to-text-models";
import { import {
AiCapabilityName, AiCapabilityName,
AiRuntimeTarget, AiRuntimeTarget,
createGeminiOpenAiClient,
createGoogleGenAiClient,
createMistralClient, createMistralClient,
createOllamaClient, createOllamaClient,
createOpenAiClient, createOpenAiClient,
getGeminiApiMode,
resolveAiRuntimeTarget, resolveAiRuntimeTarget,
sameRuntimeEndpoint, sameRuntimeEndpoint,
} from "./ai-runtime-target"; } from "./ai-runtime-target";
@@ -32,6 +35,8 @@ export function getRuntimeModel(provider: AiProvider): string {
switch (provider) { switch (provider) {
case AiProvider.OLLAMA: case AiProvider.OLLAMA:
return Environment.OLLAMA_CHAT_MODEL; return Environment.OLLAMA_CHAT_MODEL;
case AiProvider.GEMINI:
return Environment.GEMINI_MODEL;
case AiProvider.MISTRAL: case AiProvider.MISTRAL:
return Environment.MISTRAL_MODEL; return Environment.MISTRAL_MODEL;
case AiProvider.OPENAI: case AiProvider.OPENAI:
@@ -44,6 +49,9 @@ export function setRuntimeModel(provider: AiProvider, model: string): void {
case AiProvider.OLLAMA: case AiProvider.OLLAMA:
Environment.OLLAMA_CHAT_MODEL = model; Environment.OLLAMA_CHAT_MODEL = model;
break; break;
case AiProvider.GEMINI:
Environment.GEMINI_MODEL = model;
break;
case AiProvider.MISTRAL: case AiProvider.MISTRAL:
Environment.MISTRAL_MODEL = model; Environment.MISTRAL_MODEL = model;
break; break;
@@ -111,6 +119,16 @@ function isOpenAiVisionModel(model: string): boolean {
return true; return true;
} }
function isGeminiNonChatModel(model: string): boolean {
const name = lowerModelName(model);
return name.includes("lyria") || name.includes("-tts") || name.includes("image-preview") || name.endsWith("-image");
}
function geminiSupportsAudioInput(model: string): boolean {
const name = lowerModelName(model);
return name.startsWith("gemini-") && !isGeminiNonChatModel(model);
}
export async function getModelCapabilities( export async function getModelCapabilities(
provider: AiProvider, provider: AiProvider,
model: string, model: string,
@@ -143,6 +161,26 @@ export async function getModelCapabilities(
speechToText: capability(audioSupported, target, runtimeTarget), speechToText: capability(audioSupported, target, runtimeTarget),
}); });
} }
case AiProvider.GEMINI: {
const chatLike = lowerModelName(model).startsWith("gemini-") && !isGeminiNonChatModel(model);
const reasoningModel = lowerModelName(model).includes("2.5") || lowerModelName(model).includes("thinking");
const imageTarget = resolveAiRuntimeTarget(provider, "vision");
const speechTarget = resolveAiRuntimeTarget(provider, "speechToText");
const ttsTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
return buildCapabilities({
chat: capability(true, target, runtimeTarget),
vision: capability(!!imageTarget.apiKey && !!imageTarget.model, imageTarget, runtimeTarget),
ocr: capability(!!imageTarget.apiKey && !!imageTarget.model, imageTarget, runtimeTarget),
thinking: capability(reasoningModel, target, runtimeTarget),
extendedThinking: capability(reasoningModel, target, runtimeTarget),
tools: capability(chatLike, target, runtimeTarget),
audio: capability(geminiSupportsAudioInput(model), target, runtimeTarget),
speechToText: capability(!!speechTarget.apiKey && geminiSupportsAudioInput(speechTarget.model), speechTarget, runtimeTarget),
outputImages: capability(!!imageTarget.apiKey && !!imageTarget.model, imageTarget, runtimeTarget),
textToSpeech: capability(!!ttsTarget.apiKey && !!ttsTarget.model, ttsTarget, runtimeTarget),
});
}
case AiProvider.MISTRAL: { case AiProvider.MISTRAL: {
const mistral = createMistralClient(target); const mistral = createMistralClient(target);
const info = await mistral.models.retrieve({modelId: model}); const info = await mistral.models.retrieve({modelId: model});
@@ -176,7 +214,6 @@ export async function getModelCapabilities(
thinking: capability(reasoningModel, target, runtimeTarget), thinking: capability(reasoningModel, target, runtimeTarget),
extendedThinking: capability(reasoningModel, target, runtimeTarget), extendedThinking: capability(reasoningModel, target, runtimeTarget),
tools: capability(textModel, target, runtimeTarget), tools: capability(textModel, target, runtimeTarget),
documents: capability(textModel, target, runtimeTarget),
outputImages: capability(!!imageTarget.model, imageTarget, runtimeTarget), outputImages: capability(!!imageTarget.model, imageTarget, runtimeTarget),
speechToText: capability(!!speechTarget.model, speechTarget, runtimeTarget), speechToText: capability(!!speechTarget.model, speechTarget, runtimeTarget),
textToSpeech: capability(!!ttsTarget.apiKey && !!ttsTarget.model, ttsTarget, runtimeTarget), textToSpeech: capability(!!ttsTarget.apiKey && !!ttsTarget.model, ttsTarget, runtimeTarget),
@@ -185,7 +222,7 @@ export async function getModelCapabilities(
} }
} catch (e) { } catch (e) {
logError(e instanceof Error ? e : String(e)); logError(e);
return undefined; return undefined;
} }
} }
@@ -196,14 +233,9 @@ export async function getRuntimeCapabilities(
target?: AiRuntimeTarget target?: AiRuntimeTarget
): Promise<AiModelCapabilities> { ): Promise<AiModelCapabilities> {
const runtimeTarget = target ?? resolveAiRuntimeTarget(provider, "chat", model ?? getRuntimeModel(provider)); const runtimeTarget = target ?? resolveAiRuntimeTarget(provider, "chat", model ?? getRuntimeModel(provider));
const targetPurpose = target?.purpose && target.purpose !== "memoryCompress" ? target.purpose : "chat"; const result = await getModelCapabilities(provider, runtimeTarget.model, target?.purpose ?? "chat") ?? buildCapabilities({});
const result = await getModelCapabilities(provider, runtimeTarget.model, targetPurpose) ?? buildCapabilities({});
for (const capabilityName of CAPABILITY_NAMES) { for (const capabilityName of CAPABILITY_NAMES) {
if (provider === AiProvider.OPENAI && (capabilityName === "vision" || capabilityName === "ocr")) {
continue;
}
const target = resolveAiRuntimeTarget(provider, capabilityName); const target = resolveAiRuntimeTarget(provider, capabilityName);
if (target.model === runtimeTarget.model && sameRuntimeEndpoint(target, runtimeTarget)) continue; if (target.model === runtimeTarget.model && sameRuntimeEndpoint(target, runtimeTarget)) continue;
@@ -284,6 +316,23 @@ export async function listProviderModels(provider: AiProvider): Promise<string[]
const result = await ollama.list() as ModelListResponse; const result = await ollama.list() as ModelListResponse;
return (result.models ?? []).map(m => m.model || m.name).filter((name): name is string => !!name); return (result.models ?? []).map(m => m.model || m.name).filter((name): name is string => !!name);
} }
case AiProvider.GEMINI: {
const models: string[] = [];
if (getGeminiApiMode(target) === "openai") {
const geminiAi = createGeminiOpenAiClient(target);
const iterable = await geminiAi.models.list() as AsyncIterable<NamedModel>;
for await (const model of iterable) models.push(model.name || model.id || String(model));
return models;
}
const geminiAi = createGoogleGenAiClient(target);
const iterable = await geminiAi.models.list() as AsyncIterable<NamedModel>;
for await (const model of iterable) {
const name = model.name || model.id || String(model);
models.push(String(name).replace(/^models\//, ""));
}
return models;
}
case AiProvider.MISTRAL: { case AiProvider.MISTRAL: {
const mistralAi = createMistralClient(target); const mistralAi = createMistralClient(target);
const result = await mistralAi.models.list() as ModelListResponse | NamedModel[]; const result = await mistralAi.models.list() as ModelListResponse | NamedModel[];
+9 -11
View File
@@ -1,7 +1,6 @@
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider";
import {appLogger} from "../logging/logger"; import {appLogger} from "../logging/logger";
import type {BoundaryValue} from "../common/boundary-types";
const logger = appLogger.child("ai-provider-queue"); const logger = appLogger.child("ai-provider-queue");
@@ -14,16 +13,16 @@ export type AiRequestQueueTarget = {
type QueueEntry = { type QueueEntry = {
target: AiRequestQueueTarget; target: AiRequestQueueTarget;
queueKey: string; queueKey: string;
run: () => Promise<BoundaryValue>; run: () => Promise<unknown>;
resolve: (value: BoundaryValue) => void; resolve: (value: unknown) => void;
reject: (reason?: Error | string | BoundaryValue | null | undefined) => void; reject: (reason?: unknown) => void;
onPositionChange: (requestsBefore: number) => Promise<void> | void; onPositionChange: (requestsBefore: number) => Promise<void> | void;
signal?: AbortSignal; signal?: AbortSignal;
abortHandler?: () => void; abortHandler?: () => void;
started: boolean; started: boolean;
}; };
type EnqueueOptions<T extends BoundaryValue> = { type EnqueueOptions<T> = {
signal?: AbortSignal; signal?: AbortSignal;
onPositionChange: (requestsBefore: number) => Promise<void> | void; onPositionChange: (requestsBefore: number) => Promise<void> | void;
run: () => Promise<T>; run: () => Promise<T>;
@@ -33,7 +32,7 @@ class AiProviderRequestQueue {
private readonly waiting = new Map<string, QueueEntry[]>(); private readonly waiting = new Map<string, QueueEntry[]>();
private readonly active = new Map<string, number>(); private readonly active = new Map<string, number>();
enqueue<T extends BoundaryValue>(target: AiRequestQueueTarget, options: EnqueueOptions<T>): Promise<T> { enqueue<T>(target: AiRequestQueueTarget, options: EnqueueOptions<T>): Promise<T> {
if (options.signal?.aborted) { if (options.signal?.aborted) {
logger.debug("enqueue.rejected.aborted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl}); logger.debug("enqueue.rejected.aborted", {provider: target.provider, model: target.model, baseUrl: target.baseUrl});
return Promise.reject(new Error("Aborted")); return Promise.reject(new Error("Aborted"));
@@ -161,9 +160,8 @@ class AiProviderRequestQueue {
entry.resolve(await entry.run()); entry.resolve(await entry.run());
logger.debug("entry.done", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl}); logger.debug("entry.done", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl});
} catch (e) { } catch (e) {
const error = e instanceof Error ? e : String(e); logger.error("entry.failed", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl, error: e});
logger.error("entry.failed", {provider: entry.target.provider, model: entry.target.model, baseUrl: entry.target.baseUrl, error}); entry.reject(e);
entry.reject(error);
} finally { } finally {
this.setActiveCount(entry.queueKey, this.activeCount(entry.queueKey) - 1); this.setActiveCount(entry.queueKey, this.activeCount(entry.queueKey) - 1);
this.schedule(entry.target); this.schedule(entry.target);
@@ -180,10 +178,10 @@ class AiProviderRequestQueue {
})).then(results => { })).then(results => {
for (const result of results) { for (const result of results) {
if (result.status === "rejected") { if (result.status === "rejected") {
logger.error("position_update.failed", {provider: target.provider, model: target.model, reason: result.reason instanceof Error ? result.reason : String(result.reason)}); logger.error("position_update.failed", {provider: target.provider, model: target.model, reason: result.reason});
} }
} }
}).catch(error => logger.error("position_updates.failed", {provider: target.provider, model: target.model, error: error instanceof Error ? error : String(error)})); }).catch(error => logger.error("position_updates.failed", {provider: target.provider, model: target.model, error}));
} }
private deleteQueueIfIdle(queueKey: string, queue: QueueEntry[]): void { private deleteQueueIfIdle(queueKey: string, queue: QueueEntry[]): void {
-77
View File
@@ -1,77 +0,0 @@
import type {AiProvider} from "../model/ai-provider";
export type RagArtifactSource = {
fileId: string;
fileName: string;
mimeType?: string;
sizeBytes?: number;
sha256?: string;
uploadedFileId?: string;
documentId?: string;
};
export type RagArtifactPayload = {
artifactKind: "rag";
provider: AiProvider;
createdAt: string;
sources: RagArtifactSource[];
providerState:
| {
provider: AiProvider.OPENAI;
vectorStoreIds: string[];
uploadedFileIds: string[];
}
| {
provider: AiProvider.MISTRAL;
libraryId?: string;
documentCount: number;
}
| {
provider: AiProvider.OLLAMA;
prepared: boolean;
embeddingModel?: string;
topK?: number;
chunkSize?: number;
chunkOverlap?: number;
maxContextChars?: number;
extractedDocuments: Array<{
documentIndex: number;
fileName: string;
textChars: number;
}>;
selectedChunks: Array<{
sourceId: string;
documentIndex: number;
documentName: string;
chunkIndex: number;
chunkCount: number;
textChars: number;
score?: number;
}>;
skippedDocuments: Array<{
documentIndex: number;
fileName: string;
reason: string;
}>;
query: string;
minScore: number;
maxArchiveFiles: number;
maxArchiveBytes: number;
maxArchiveDepth: number;
};
};
export function buildRagArtifactPayload(params: {
provider: AiProvider;
createdAt?: string;
sources: RagArtifactSource[];
providerState: RagArtifactPayload["providerState"];
}): RagArtifactPayload {
return {
artifactKind: "rag",
provider: params.provider,
createdAt: params.createdAt ?? new Date().toISOString(),
sources: params.sources,
providerState: params.providerState,
};
}
-100
View File
@@ -1,100 +0,0 @@
import type {StoredAttachment} from "../model/stored-attachment";
import {AiProvider} from "../model/ai-provider";
import type {AiDownloadedFile} from "./telegram-attachments";
import type {PreparedDocumentRag} from "./document-rag-pipeline";
import type {OllamaRagArtifactDetails} from "./ollama-rag";
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
import {buildRagArtifactPayload, type RagArtifactPayload} from "./rag-artifact-payload";
function providerState(prepared: PreparedDocumentRag, details?: NonNullable<Parameters<typeof persistRagArtifactAttachment>[0]["details"]>): RagArtifactPayload["providerState"] {
switch (prepared.provider) {
case AiProvider.OPENAI:
return {
provider: AiProvider.OPENAI,
vectorStoreIds: prepared.vectorStoreIds,
uploadedFileIds: prepared.uploadedFileIds,
};
case AiProvider.MISTRAL:
return {
provider: AiProvider.MISTRAL,
libraryId: prepared.libraryId,
documentCount: prepared.documents.length,
};
case AiProvider.OLLAMA:
return {
provider: AiProvider.OLLAMA,
prepared: prepared.prepared,
embeddingModel: details?.embeddingModel,
topK: details?.topK,
chunkSize: details?.chunkSize,
chunkOverlap: details?.chunkOverlap,
maxContextChars: details?.maxContextChars,
extractedDocuments: details?.artifact?.extractedDocuments ?? [],
selectedChunks: details?.artifact?.selectedChunks ?? [],
skippedDocuments: details?.artifact?.skippedDocuments ?? [],
query: details?.artifact?.query ?? "",
minScore: details?.artifact?.providerState?.minScore ?? 0,
maxArchiveFiles: details?.artifact?.providerState?.maxArchiveFiles ?? 0,
maxArchiveBytes: details?.artifact?.providerState?.maxArchiveBytes ?? 0,
maxArchiveDepth: details?.artifact?.providerState?.maxArchiveDepth ?? 0,
};
}
}
export async function persistRagArtifactAttachment(params: {
provider: AiProvider;
prepared: PreparedDocumentRag | undefined;
downloads: AiDownloadedFile[];
chatId: number;
messageId: number;
details?: {
uploadedFileIds?: string[];
sourceDocuments?: Array<{
fileId: string;
fileName: string;
mimeType?: string;
sizeBytes?: number;
sha256?: string;
uploadedFileId?: string;
documentId?: string;
}>;
embeddingModel?: string;
topK?: number;
chunkSize?: number;
chunkOverlap?: number;
maxContextChars?: number;
artifact?: OllamaRagArtifactDetails;
};
}): Promise<StoredAttachment | undefined> {
if (!params.prepared) return Promise.resolve(undefined);
const sources = params.downloads
.filter(download => download.kind === "document")
.map((download, index) => ({
fileId: download.fileId,
fileName: download.fileName,
mimeType: download.mimeType,
sizeBytes: download.sizeBytes ?? download.buffer.length,
sha256: download.sha256,
uploadedFileId: params.details?.uploadedFileIds?.[index],
}));
if (!sources.length) return Promise.resolve(undefined);
const payload = buildRagArtifactPayload({
provider: params.provider,
sources,
providerState: providerState(params.prepared, params.details),
});
return await persistInternalJsonArtifactAttachment({
artifactKind: "rag",
fileNamePrefix: "rag",
chatId: params.chatId,
messageId: params.messageId,
payload,
metadata: {
sourceFileNames: sources.map(source => source.fileName),
...payload.providerState,
},
});
}
-75
View File
@@ -1,75 +0,0 @@
import type {RagArtifactPayload} from "./rag-artifact-payload";
export type ArtifactLike = {
id: string;
createdAt: string;
payload: string;
};
export type RagCleanupTarget = {
artifactId: string;
createdAt: string;
provider: RagArtifactPayload["providerState"]["provider"];
vectorStoreIds?: string[];
uploadedFileIds?: string[];
libraryId?: string;
};
export type RagCleanupPlan = {
cutoffAt: string;
targets: RagCleanupTarget[];
};
function parseRagArtifactPayload(payload: string): RagArtifactPayload | null {
try {
const parsed = JSON.parse(payload) as Partial<RagArtifactPayload>;
if (!parsed || parsed.artifactKind !== "rag" || !parsed.providerState) return null;
return parsed as RagArtifactPayload;
} catch {
return null;
}
}
export function buildStaleRagCleanupPlan(
artifacts: ArtifactLike[],
retentionDays = 14,
now = new Date(),
): RagCleanupPlan {
const cutoffAt = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
const targets: RagCleanupTarget[] = [];
for (const artifact of artifacts) {
if (artifact.createdAt > cutoffAt) continue;
const payload = parseRagArtifactPayload(artifact.payload);
if (!payload || payload.artifactKind !== "rag") continue;
switch (payload.providerState.provider) {
case "OPENAI":
if (payload.providerState.vectorStoreIds.length || payload.providerState.uploadedFileIds.length) {
targets.push({
artifactId: artifact.id,
createdAt: artifact.createdAt,
provider: payload.providerState.provider,
vectorStoreIds: [...payload.providerState.vectorStoreIds],
uploadedFileIds: [...payload.providerState.uploadedFileIds],
});
}
break;
case "MISTRAL":
if (payload.providerState.libraryId) {
targets.push({
artifactId: artifact.id,
createdAt: artifact.createdAt,
provider: payload.providerState.provider,
libraryId: payload.providerState.libraryId,
});
}
break;
case "OLLAMA":
break;
}
}
return {cutoffAt, targets};
}
-117
View File
@@ -1,117 +0,0 @@
import {appLogger} from "../logging/logger.js";
import {DatabaseManager} from "../db/database-manager.js";
import {AiProvider} from "../model/ai-provider.js";
import {createOpenAiClient, resolveAiRuntimeTarget} from "./ai-runtime-target.js";
import {deleteMistralLibrary} from "./unified-ai-runner.shared.js";
import {buildStaleRagCleanupPlan} from "./rag-retention-planner.js";
const logger = appLogger.child("rag-retention");
function unique(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
async function cleanupOpenAiRag(vectorStoreIds: string[], uploadedFileIds: string[]): Promise<void> {
const target = resolveAiRuntimeTarget(AiProvider.OPENAI, "documents");
const client = createOpenAiClient(target);
for (const vectorStoreId of unique(vectorStoreIds)) {
const startedAt = Date.now();
logger.info("openai.vector_store.cleanup.start", {vectorStoreId});
try {
await client.vectorStores.delete(vectorStoreId);
logger.success("openai.vector_store.cleanup.done", {vectorStoreId, duration: `${Date.now() - startedAt}ms`});
} catch (error) {
logger.warn("openai.vector_store.cleanup.failed", {
vectorStoreId,
duration: `${Date.now() - startedAt}ms`,
error: error instanceof Error ? error : String(error),
});
}
}
for (const fileId of unique(uploadedFileIds)) {
const startedAt = Date.now();
logger.info("openai.file.cleanup.start", {fileId});
try {
await client.files.delete(fileId);
logger.success("openai.file.cleanup.done", {fileId, duration: `${Date.now() - startedAt}ms`});
} catch (error) {
logger.warn("openai.file.cleanup.failed", {
fileId,
duration: `${Date.now() - startedAt}ms`,
error: error instanceof Error ? error : String(error),
});
}
}
}
async function cleanupMistralRag(libraryId: string): Promise<void> {
const target = resolveAiRuntimeTarget(AiProvider.MISTRAL, "documents");
const startedAt = Date.now();
logger.info("mistral.library.cleanup.start", {libraryId});
try {
await deleteMistralLibrary(libraryId, target);
logger.success("mistral.library.cleanup.done", {libraryId, duration: `${Date.now() - startedAt}ms`});
} catch (error) {
logger.warn("mistral.library.cleanup.failed", {
libraryId,
duration: `${Date.now() - startedAt}ms`,
error: error instanceof Error ? error : String(error),
});
}
}
export async function cleanupStaleRagProviderState(retentionDays = 14): Promise<{
scannedArtifacts: number;
cleanupTargets: number;
openaiTargets: number;
mistralTargets: number;
}> {
const startedAt = Date.now();
const artifacts = await DatabaseManager.getAllArtifacts().catch(() => []);
const plan = buildStaleRagCleanupPlan(artifacts, retentionDays);
logger.info("cleanup.start", {
retentionDays,
scannedArtifacts: artifacts.length,
cleanupTargets: plan.targets.length,
cutoffAt: plan.cutoffAt,
});
let openaiTargets = 0;
let mistralTargets = 0;
for (const target of plan.targets) {
switch (target.provider) {
case "OPENAI":
openaiTargets += 1;
await cleanupOpenAiRag(target.vectorStoreIds ?? [], target.uploadedFileIds ?? []);
break;
case "MISTRAL":
mistralTargets += 1;
if (target.libraryId) {
await cleanupMistralRag(target.libraryId);
}
break;
case "OLLAMA":
break;
}
}
logger.success("cleanup.done", {
retentionDays,
scannedArtifacts: artifacts.length,
cleanupTargets: plan.targets.length,
openaiTargets,
mistralTargets,
duration: `${Date.now() - startedAt}ms`,
});
return {
scannedArtifacts: artifacts.length,
cleanupTargets: plan.targets.length,
openaiTargets,
mistralTargets,
};
}
-39
View File
@@ -1,39 +0,0 @@
import type {AiDownloadedFile} from "./telegram-attachments.js";
function downloadKey(download: AiDownloadedFile): string {
return [
download.kind,
download.fileId,
download.sha256 ?? "",
download.fileName,
].join(":");
}
export function mergeReplyChainDownloads(
currentDownloads: readonly AiDownloadedFile[],
replyChainDownloads: readonly AiDownloadedFile[],
): AiDownloadedFile[] {
const result: AiDownloadedFile[] = [];
const seen = new Set<string>();
for (const download of [...currentDownloads, ...replyChainDownloads]) {
const key = downloadKey(download);
if (seen.has(key)) continue;
seen.add(key);
result.push(download);
}
return result;
}
export function shouldPreferCurrentDownloads(text: string, currentDownloads: readonly AiDownloadedFile[]): boolean {
if (!currentDownloads.length) return false;
const normalized = text.trim().toLowerCase();
if (!normalized) return false;
return normalized.includes("this file")
|| normalized.includes("this document")
|| normalized.includes("этот файл")
|| normalized.includes("этот документ");
}
-19
View File
@@ -1,19 +0,0 @@
import type {TelegramOutputAttachmentRecord, TelegramToolExecutionRecord} from "./telegram-stream-message.js";
export type NormalizedModelOutput = {
text: string;
toolExecutions: TelegramToolExecutionRecord[];
outputAttachments: TelegramOutputAttachmentRecord[];
};
export function summarizeModelOutput(params: {
text: string;
toolExecutions: readonly TelegramToolExecutionRecord[];
outputAttachments: readonly TelegramOutputAttachmentRecord[];
}): NormalizedModelOutput {
return {
text: params.text.trim(),
toolExecutions: [...params.toolExecutions],
outputAttachments: [...params.outputAttachments],
};
}
+67 -4
View File
@@ -2,13 +2,19 @@ import fs, {openAsBlob} from "node:fs";
import {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider";
import { import {
getAvailableAiProviderChoices, getAvailableAiProviderChoices,
getProviderChoiceLabel,
normalizeAiProviderChoice, normalizeAiProviderChoice,
resolveEffectiveAiProviderForUser, resolveEffectiveAiProviderForUser,
} from "../common/user-ai-settings"; } from "../common/user-ai-settings";
import {providerDisplayName} from "./provider-aliases";
import {AiDownloadedFile} from "./telegram-attachments"; import {AiDownloadedFile} from "./telegram-attachments";
import {isOllamaSpeechToTextModel} from "./speech-to-text-models"; import {isOllamaSpeechToTextModel} from "./speech-to-text-models";
import {createMistralClient, createOllamaClient, createOpenAiClient, resolveAiRuntimeTarget} from "./ai-runtime-target"; import {
createGoogleGenAiClient,
createMistralClient,
createOllamaClient,
createOpenAiClient,
resolveAiRuntimeTarget
} from "./ai-runtime-target";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
export type TranscribedSpeech = { export type TranscribedSpeech = {
@@ -33,6 +39,10 @@ export type SpeechToTextResolveOptions = {
allowFallback?: boolean; allowFallback?: boolean;
}; };
function providerName(provider: AiProvider): string {
return getProviderChoiceLabel(provider);
}
export function isTranscribableAudioDownload(download: AiDownloadedFile): boolean { export function isTranscribableAudioDownload(download: AiDownloadedFile): boolean {
if (download.kind === "audio") return true; if (download.kind === "audio") return true;
return download.kind === "video-note" && (download.mimeType?.startsWith("audio/") || download.path.toLowerCase().endsWith(".wav")); return download.kind === "video-note" && (download.mimeType?.startsWith("audio/") || download.path.toLowerCase().endsWith(".wav"));
@@ -43,6 +53,9 @@ export function isSpeechToTextConfigured(provider: AiProvider): boolean {
case AiProvider.OPENAI: case AiProvider.OPENAI:
const openAiTarget = resolveAiRuntimeTarget(provider, "speechToText"); const openAiTarget = resolveAiRuntimeTarget(provider, "speechToText");
return !!openAiTarget.apiKey && !!openAiTarget.model; return !!openAiTarget.apiKey && !!openAiTarget.model;
case AiProvider.GEMINI:
const geminiTarget = resolveAiRuntimeTarget(provider, "speechToText");
return !!geminiTarget.apiKey && !!geminiTarget.model;
case AiProvider.MISTRAL: case AiProvider.MISTRAL:
const mistralTarget = resolveAiRuntimeTarget(provider, "speechToText"); const mistralTarget = resolveAiRuntimeTarget(provider, "speechToText");
return !!mistralTarget.apiKey && !!mistralTarget.model; return !!mistralTarget.apiKey && !!mistralTarget.model;
@@ -65,7 +78,7 @@ export async function resolveSpeechToTextProviderForUser(
if (preferredProvider) { if (preferredProvider) {
if (!allowedProviders.includes(preferredProvider)) { if (!allowedProviders.includes(preferredProvider)) {
throw new Error(Environment.getProviderNotAvailableForAccessText(providerDisplayName(preferredProvider))); throw new Error(Environment.getProviderNotAvailableForAccessText(providerName(preferredProvider)));
} }
if (isSpeechToTextConfigured(preferredProvider)) { if (isSpeechToTextConfigured(preferredProvider)) {
@@ -73,7 +86,7 @@ export async function resolveSpeechToTextProviderForUser(
} }
if (!allowFallback) { if (!allowFallback) {
throw new Error(Environment.getProviderSpeechToTextUnsupportedText(providerDisplayName(preferredProvider))); throw new Error(Environment.getProviderSpeechToTextUnsupportedText(providerName(preferredProvider)));
} }
} }
@@ -99,6 +112,8 @@ export async function transcribeSpeech(request: SpeechToTextRequest): Promise<Tr
switch (request.provider) { switch (request.provider) {
case AiProvider.OPENAI: case AiProvider.OPENAI:
return transcribeOpenAiSpeech(request.audio, request.signal); return transcribeOpenAiSpeech(request.audio, request.signal);
case AiProvider.GEMINI:
return transcribeGeminiSpeech(request.audio, request.signal);
case AiProvider.MISTRAL: case AiProvider.MISTRAL:
return transcribeMistralSpeech(request.audio, request.signal); return transcribeMistralSpeech(request.audio, request.signal);
case AiProvider.OLLAMA: case AiProvider.OLLAMA:
@@ -162,6 +177,37 @@ async function transcribeMistralSpeech(audio: AiDownloadedFile, signal?: AbortSi
}; };
} }
async function transcribeGeminiSpeech(audio: AiDownloadedFile, signal?: AbortSignal): Promise<TranscribedSpeech> {
const target = resolveAiRuntimeTarget(AiProvider.GEMINI, "speechToText");
const geminiAi = createGoogleGenAiClient(target);
const response = await geminiAi.models.generateContent({
model: target.model,
contents: [{
role: "user",
parts: [
{text: "Transcribe the attached audio verbatim. Reply only with the transcription text. Do not answer the speaker."},
{
inlineData: {
data: audio.buffer.toString("base64"),
mimeType: audio.mimeType || "audio/wav",
}
}
]
}],
config: {
temperature: 0,
abortSignal: signal,
},
}) as unknown as GeminiSpeechResponse;
return {
provider: AiProvider.GEMINI,
model: target.model,
text: collectGeminiText(response),
fileName: audio.fileName,
};
}
async function transcribeOllamaSpeech(audio: AiDownloadedFile, signal?: AbortSignal): Promise<TranscribedSpeech> { async function transcribeOllamaSpeech(audio: AiDownloadedFile, signal?: AbortSignal): Promise<TranscribedSpeech> {
if (signal?.aborted) throw new Error("Aborted"); if (signal?.aborted) throw new Error("Aborted");
@@ -193,3 +239,20 @@ async function transcribeOllamaSpeech(audio: AiDownloadedFile, signal?: AbortSig
fileName: audio.fileName, fileName: audio.fileName,
}; };
} }
type GeminiSpeechResponse = {
text?: string;
candidates?: Array<{content?: {parts?: Array<{text?: string}>}}> ;
};
function collectGeminiText(response: GeminiSpeechResponse): string {
if (typeof response.text === "string") return response.text;
const candidateText = (response.candidates ?? [])
.flatMap(candidate => candidate.content?.parts ?? [])
.map(part => part.text ?? "")
.join("");
if (candidateText.trim()) return candidateText;
return "";
}
+16 -210
View File
@@ -9,8 +9,6 @@ import {performFFmpeg} from "../util/ffmpeg";
import ffmpeg from "fluent-ffmpeg"; import ffmpeg from "fluent-ffmpeg";
import {AsyncSemaphore, KeyedAsyncLock} from "../util/async-lock"; import {AsyncSemaphore, KeyedAsyncLock} from "../util/async-lock";
import {appLogger} from "../logging/logger"; import {appLogger} from "../logging/logger";
import {createHash} from "node:crypto";
import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./user-request-pipeline/types";
export type AiDownloadedFile = { export type AiDownloadedFile = {
kind: StoredAttachmentKind; kind: StoredAttachmentKind;
@@ -19,33 +17,6 @@ export type AiDownloadedFile = {
mimeType?: string; mimeType?: string;
buffer: Buffer; buffer: Buffer;
path: string; path: string;
sizeBytes?: number;
sha256?: string;
};
export type RejectedTelegramAttachment = {
kind: StoredAttachmentKind;
fileId: string;
fileUniqueId?: string;
fileName: string;
mimeType?: string;
sizeBytes: number;
limitBytes: number;
reason: "too_large";
};
export type TelegramAttachmentDescriptor = {
kind: StoredAttachmentKind;
fileId: string;
fileUniqueId?: string;
fileName: string;
mimeType?: string;
sizeBytes?: number;
};
export type MessageAttachmentCacheResult = {
attachments: StoredAttachment[];
rejected: RejectedTelegramAttachment[];
}; };
const cachePathLocks = new KeyedAsyncLock(); const cachePathLocks = new KeyedAsyncLock();
@@ -120,113 +91,7 @@ function cachePathFor(kind: StoredAttachmentKind, fileUniqueId: string | undefin
return path.join(cacheDirFor(kind), `${base}${ext || ""}`); return path.join(cacheDirFor(kind), `${base}${ext || ""}`);
} }
function fileSha256(location: string): string | undefined { async function downloadToCache(kind: StoredAttachmentKind, fileId: string, fileName: string, mimeType?: string, fileUniqueId?: string): Promise<StoredAttachment | null> {
if (!fs.existsSync(location)) return undefined;
return createHash("sha256").update(fs.readFileSync(location)).digest("hex");
}
function rejectIfTooLarge(
rejected: RejectedTelegramAttachment[],
kind: StoredAttachmentKind,
fileId: string,
fileName: string,
mimeType?: string,
sizeBytes?: number,
fileUniqueId?: string,
): boolean {
if (!sizeBytes || sizeBytes <= PIPELINE_ATTACHMENT_LIMIT_BYTES) {
return false;
}
rejected.push({
kind,
fileId,
fileUniqueId,
fileName,
mimeType,
sizeBytes,
limitBytes: PIPELINE_ATTACHMENT_LIMIT_BYTES,
reason: "too_large",
});
logger.warn("message.cache.rejected.too_large", {kind, fileId, fileName, mimeType, sizeBytes});
return true;
}
export function collectTelegramAttachmentDescriptors(msg: Message): TelegramAttachmentDescriptor[] {
const attachments: TelegramAttachmentDescriptor[] = [];
if (msg.photo?.length) {
const size = msg.photo[msg.photo.length - 1]!;
attachments.push({
kind: "image",
fileId: size.file_id,
fileUniqueId: size.file_unique_id,
fileName: `${size.file_unique_id || size.file_id}.jpg`,
mimeType: "image/jpeg",
sizeBytes: size.file_size,
});
}
if (msg.document) {
const doc = msg.document;
attachments.push({
kind: doc.mime_type?.startsWith("image/")
? "image"
: doc.mime_type?.startsWith("audio/")
? "audio"
: "document",
fileId: doc.file_id,
fileUniqueId: doc.file_unique_id,
fileName: doc.file_name || `${doc.file_unique_id || doc.file_id}`,
mimeType: doc.mime_type,
sizeBytes: doc.file_size,
});
}
if (msg.voice) {
attachments.push({
kind: "audio",
fileId: msg.voice.file_id,
fileUniqueId: msg.voice.file_unique_id,
fileName: `${msg.voice.file_unique_id || msg.voice.file_id}.ogg`,
mimeType: msg.voice.mime_type || "audio/ogg",
sizeBytes: msg.voice.file_size,
});
}
if (msg.audio) {
attachments.push({
kind: "audio",
fileId: msg.audio.file_id,
fileUniqueId: msg.audio.file_unique_id,
fileName: msg.audio.file_name || `${msg.audio.file_unique_id || msg.audio.file_id}.mp3`,
mimeType: msg.audio.mime_type,
sizeBytes: msg.audio.file_size,
});
}
if (msg.video_note) {
attachments.push({
kind: "video-note",
fileId: msg.video_note.file_id,
fileUniqueId: msg.video_note.file_unique_id,
fileName: `${msg.video_note.file_unique_id || msg.video_note.file_id}.mp4`,
mimeType: "video/mp4",
sizeBytes: msg.video_note.file_size,
});
}
return attachments;
}
async function downloadToCache(
kind: StoredAttachmentKind,
fileId: string,
fileName: string,
mimeType?: string,
fileUniqueId?: string,
sizeBytes?: number,
): Promise<StoredAttachment | null> {
const startedAt = Date.now(); const startedAt = Date.now();
logger.debug("download.start", {kind, fileId, fileName, mimeType}); logger.debug("download.start", {kind, fileId, fileName, mimeType});
const file = await bot.getFile({file_id: fileId}); const file = await bot.getFile({file_id: fileId});
@@ -252,17 +117,7 @@ async function downloadToCache(
logger.debug("download.saved", {kind, location, bytes: buffer.length, duration: logger.duration(startedAt)}); logger.debug("download.saved", {kind, location, bytes: buffer.length, duration: logger.duration(startedAt)});
}); });
const resolvedSizeBytes = sizeBytes ?? (fs.existsSync(location) ? fs.statSync(location).size : undefined); return {kind, fileId, fileUniqueId, fileName: finalFileName, mimeType, cachePath: location};
return {
kind,
fileId,
fileUniqueId,
fileName: finalFileName,
mimeType,
cachePath: location,
sizeBytes: resolvedSizeBytes,
sha256: fileSha256(location),
};
} }
async function convertAudioToWav(input: string, output: string, noVideo = false): Promise<void> { async function convertAudioToWav(input: string, output: string, noVideo = false): Promise<void> {
@@ -298,29 +153,24 @@ async function convertAudioToWav(input: string, output: string, noVideo = false)
if (fs.existsSync(tempOutput)) { if (fs.existsSync(tempOutput)) {
fs.rmSync(tempOutput, {force: true}); fs.rmSync(tempOutput, {force: true});
} }
logger.error("audio.convert.failed", {input, output, error: e instanceof Error ? e : String(e)}); logger.error("audio.convert.failed", {input, output, error: e});
throw e; throw e;
} }
}); });
}); });
} }
export async function cacheMessageAttachmentsWithRejections(msg: Message): Promise<MessageAttachmentCacheResult> { export async function cacheMessageAttachments(msg: Message): Promise<StoredAttachment[]> {
const startedAt = Date.now(); const startedAt = Date.now();
const result: StoredAttachment[] = []; const result: StoredAttachment[] = [];
const rejected: RejectedTelegramAttachment[] = [];
logger.debug("message.cache.start", {chatId: msg.chat?.id, messageId: msg.message_id}); logger.debug("message.cache.start", {chatId: msg.chat?.id, messageId: msg.message_id});
try { try {
if (msg.photo?.length) { if (msg.photo?.length) {
const size = msg.photo[msg.photo.length - 1]!; const size = msg.photo[msg.photo.length - 1]!;
const fileName = `${size.file_unique_id || size.file_id}.jpg`; const file = await downloadToCache("image", size.file_id, `${size.file_unique_id || size.file_id}.jpg`, "image/jpeg", size.file_unique_id);
const mimeType = "image/jpeg";
if (!rejectIfTooLarge(rejected, "image", size.file_id, fileName, mimeType, size.file_size, size.file_unique_id)) {
const file = await downloadToCache("image", size.file_id, fileName, mimeType, size.file_unique_id, size.file_size);
if (file) result.push(file); if (file) result.push(file);
} }
}
if (msg.document) { if (msg.document) {
const doc = msg.document; const doc = msg.document;
@@ -329,19 +179,12 @@ export async function cacheMessageAttachmentsWithRejections(msg: Message): Promi
: doc.mime_type?.startsWith("audio/") : doc.mime_type?.startsWith("audio/")
? "audio" ? "audio"
: "document"; : "document";
const fileName = doc.file_name || `${doc.file_unique_id || doc.file_id}`; const file = await downloadToCache(kind, doc.file_id, doc.file_name || `${doc.file_unique_id || doc.file_id}`, doc.mime_type, doc.file_unique_id);
if (!rejectIfTooLarge(rejected, kind, doc.file_id, fileName, doc.mime_type, doc.file_size, doc.file_unique_id)) {
const file = await downloadToCache(kind, doc.file_id, fileName, doc.mime_type, doc.file_unique_id, doc.file_size);
if (file) result.push(file); if (file) result.push(file);
} }
}
if (msg.voice) { if (msg.voice) {
const fileName = `${msg.voice.file_unique_id || msg.voice.file_id}.ogg`; const file = await downloadToCache("audio", msg.voice.file_id, `${msg.voice.file_unique_id || msg.voice.file_id}.ogg`, msg.voice.mime_type || "audio/ogg", msg.voice.file_unique_id);
const mimeType = msg.voice.mime_type || "audio/ogg";
const file = rejectIfTooLarge(rejected, "audio", msg.voice.file_id, fileName, mimeType, msg.voice.file_size, msg.voice.file_unique_id)
? null
: await downloadToCache("audio", msg.voice.file_id, fileName, mimeType, msg.voice.file_unique_id, msg.voice.file_size);
if (file) { if (file) {
const output = cachePathFor("audio", msg.voice.file_unique_id, msg.voice.file_id, `${msg.voice.file_unique_id || msg.voice.file_id}.wav`); const output = cachePathFor("audio", msg.voice.file_unique_id, msg.voice.file_id, `${msg.voice.file_unique_id || msg.voice.file_id}.wav`);
try { try {
@@ -349,10 +192,8 @@ export async function cacheMessageAttachmentsWithRejections(msg: Message): Promi
file.cachePath = output; file.cachePath = output;
file.fileName = file?.fileName?.replace(".ogg", ".wav"); file.fileName = file?.fileName?.replace(".ogg", ".wav");
file.mimeType = "audio/wav"; file.mimeType = "audio/wav";
file.sizeBytes = fs.existsSync(output) ? fs.statSync(output).size : file.sizeBytes;
file.sha256 = fileSha256(output);
} catch (e) { } catch (e) {
logError(e instanceof Error ? e : String(e)); logError(e);
} }
} }
@@ -360,19 +201,12 @@ export async function cacheMessageAttachmentsWithRejections(msg: Message): Promi
} }
if (msg.audio) { if (msg.audio) {
const fileName = msg.audio.file_name || `${msg.audio.file_unique_id || msg.audio.file_id}.mp3`; const file = await downloadToCache("audio", msg.audio.file_id, msg.audio.file_name || `${msg.audio.file_unique_id || msg.audio.file_id}.mp3`, msg.audio.mime_type, msg.audio.file_unique_id);
if (!rejectIfTooLarge(rejected, "audio", msg.audio.file_id, fileName, msg.audio.mime_type, msg.audio.file_size, msg.audio.file_unique_id)) {
const file = await downloadToCache("audio", msg.audio.file_id, fileName, msg.audio.mime_type, msg.audio.file_unique_id, msg.audio.file_size);
if (file) result.push(file); if (file) result.push(file);
} }
}
if (msg.video_note) { if (msg.video_note) {
const fileName = `${msg.video_note.file_unique_id || msg.video_note.file_id}.mp4`; const file = await downloadToCache("video-note", msg.video_note.file_id, `${msg.video_note.file_unique_id || msg.video_note.file_id}.mp4`, "video/mp4", msg.video_note.file_unique_id);
const mimeType = "video/mp4";
const file = rejectIfTooLarge(rejected, "video-note", msg.video_note.file_id, fileName, mimeType, msg.video_note.file_size, msg.video_note.file_unique_id)
? null
: await downloadToCache("video-note", msg.video_note.file_id, fileName, mimeType, msg.video_note.file_unique_id, msg.video_note.file_size);
if (file) { if (file) {
const output = cachePathFor("audio", msg.video_note.file_unique_id, msg.video_note.file_id, `${msg.video_note.file_unique_id || msg.video_note.file_id}.wav`); const output = cachePathFor("audio", msg.video_note.file_unique_id, msg.video_note.file_id, `${msg.video_note.file_unique_id || msg.video_note.file_id}.wav`);
try { try {
@@ -380,61 +214,33 @@ export async function cacheMessageAttachmentsWithRejections(msg: Message): Promi
file.cachePath = output; file.cachePath = output;
file.fileName = file?.fileName?.replace(".mp4", ".wav"); file.fileName = file?.fileName?.replace(".mp4", ".wav");
file.mimeType = "audio/wav"; file.mimeType = "audio/wav";
file.sizeBytes = fs.existsSync(output) ? fs.statSync(output).size : file.sizeBytes;
file.sha256 = fileSha256(output);
} catch (e) { } catch (e) {
logError(e instanceof Error ? e : String(e)); logError(e);
} }
} }
if (file) result.push(file); if (file) result.push(file);
} }
} catch (e) { } catch (e) {
logError(e instanceof Error ? e : String(e)); logError(e);
} }
logger.debug("message.cache.done", { logger.debug("message.cache.done", {chatId: msg.chat?.id, messageId: msg.message_id, attachments: result.length, duration: logger.duration(startedAt)});
chatId: msg.chat?.id, return result;
messageId: msg.message_id,
attachments: result.length,
rejected: rejected.length,
duration: logger.duration(startedAt),
});
return {attachments: result, rejected};
}
export async function cacheMessageAttachments(msg: Message): Promise<StoredAttachment[]> {
const {attachments} = await cacheMessageAttachmentsWithRejections(msg);
return attachments;
} }
export function attachmentsToDownloadedFiles(attachments: StoredAttachment[]): AiDownloadedFile[] { export function attachmentsToDownloadedFiles(attachments: StoredAttachment[]): AiDownloadedFile[] {
logger.trace("downloaded_files.build", {attachments: attachments.length}); logger.trace("downloaded_files.build", {attachments: attachments.length});
return attachments return attachments
.filter(attachment => fs.existsSync(attachment.cachePath)) .filter(attachment => fs.existsSync(attachment.cachePath))
.flatMap(attachment => { .map(attachment => ({
const sizeBytes = attachment.sizeBytes ?? fs.statSync(attachment.cachePath).size;
if (sizeBytes > PIPELINE_ATTACHMENT_LIMIT_BYTES) {
logger.warn("downloaded_files.skipped.too_large", {
kind: attachment.kind,
fileName: attachment.fileName,
sizeBytes,
limitBytes: PIPELINE_ATTACHMENT_LIMIT_BYTES,
});
return [];
}
return [{
kind: attachment.kind, kind: attachment.kind,
fileId: attachment.fileId, fileId: attachment.fileId,
fileName: attachment.fileName, fileName: attachment.fileName,
mimeType: attachment.mimeType, mimeType: attachment.mimeType,
buffer: fs.readFileSync(attachment.cachePath), buffer: fs.readFileSync(attachment.cachePath),
path: attachment.cachePath, path: attachment.cachePath,
sizeBytes, }));
sha256: attachment.sha256,
}];
});
} }
export function cleanupDownloads(files: AiDownloadedFile[]): void { export function cleanupDownloads(files: AiDownloadedFile[]): void {
+31 -217
View File
@@ -5,22 +5,17 @@ import {Environment} from "../common/environment";
import {MessageStore} from "../common/message-store"; import {MessageStore} from "../common/message-store";
import {createQueuedFunction} from "../util/async-lock"; import {createQueuedFunction} from "../util/async-lock";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {appLogger} from "../logging/logger";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path";
import {StoredAttachment, StoredAttachmentKind} from "../model/stored-attachment"; import {StoredAttachment, StoredAttachmentKind} from "../model/stored-attachment";
import {StoredMessage} from "../model/stored-message"; import {StoredMessage} from "../model/stored-message";
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer"; import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
import {AiProvider} from "../model/ai-provider"; import {AiProvider} from "../model/ai-provider";
import {AI_IMAGE_OUTPUT_MODE_DOCUMENT, UserAiImageOutputMode} from "../common/user-ai-settings";
import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./user-request-pipeline";
import {recordToolCall} from "../common/ai-observability.js";
const TELEGRAM_LIMIT = 4096; const TELEGRAM_LIMIT = 4096;
const TELEGRAM_CAPTION_LIMIT = 1024; const TELEGRAM_CAPTION_LIMIT = 1024;
const TELEGRAM_FILE_LIMIT_BYTES = 50 * 1024 * 1024;
const TELEGRAM_PHOTO_LIMIT_BYTES = 10 * 1024 * 1024; const TELEGRAM_PHOTO_LIMIT_BYTES = 10 * 1024 * 1024;
const EDIT_INTERVAL_MS = 4500; const EDIT_INTERVAL_MS = 4500;
const logger = appLogger.child("telegram-stream-message");
export type TelegramArtifactFile = { export type TelegramArtifactFile = {
kind: "image" | "file"; kind: "image" | "file";
@@ -30,23 +25,6 @@ export type TelegramArtifactFile = {
sizeBytes: number; sizeBytes: number;
}; };
export type TelegramToolExecutionRecord = {
toolName: string;
callId: string;
argumentsText: string;
resultChars: number;
startedAt: string;
finishedAt: string;
};
export type TelegramOutputAttachmentRecord = {
artifactKind: "generated_file" | "tts_audio";
fileName: string;
mimeType?: string;
sizeBytes?: number;
messageId?: number;
};
export class TelegramStreamMessage { export class TelegramStreamMessage {
private waitMessage: Message | null = null; private waitMessage: Message | null = null;
private timer: NodeJS.Timeout | null = null; private timer: NodeJS.Timeout | null = null;
@@ -56,11 +34,8 @@ export class TelegramStreamMessage {
private mediaMode = false; private mediaMode = false;
private cancelled = false; private cancelled = false;
private cancelledProvider = ""; private cancelledProvider = "";
private readonly sendImagesAsDocuments: boolean;
private readonly startedAt = Date.now(); private readonly startedAt = Date.now();
private readonly enqueueEdit = createQueuedFunction(); private readonly enqueueEdit = createQueuedFunction();
private readonly toolExecutions: TelegramToolExecutionRecord[] = [];
private readonly outputAttachments: TelegramOutputAttachmentRecord[] = [];
constructor( constructor(
private readonly sourceMessage: Message, private readonly sourceMessage: Message,
@@ -70,9 +45,7 @@ export class TelegramStreamMessage {
private readonly targetMessage?: Message, private readonly targetMessage?: Message,
private readonly cancelProvider?: AiProvider, private readonly cancelProvider?: AiProvider,
private readonly isGuest?: boolean, private readonly isGuest?: boolean,
imageOutputMode: UserAiImageOutputMode = "photo",
) { ) {
this.sendImagesAsDocuments = imageOutputMode === AI_IMAGE_OUTPUT_MODE_DOCUMENT;
} }
keyboard(): InlineKeyboardMarkup { keyboard(): InlineKeyboardMarkup {
@@ -101,8 +74,18 @@ export class TelegramStreamMessage {
}; };
} }
private isMessageNotModified(message: string): boolean { private isMessageNotModified(error: unknown): boolean {
return message.includes("message is not modified"); const textToLookUp = "message is not modified";
if (error && error instanceof Error) {
return String(error.message).includes(textToLookUp);
}
if (error && error instanceof String) {
return error.includes(textToLookUp);
}
return false;
} }
private async updateKeyboard(replyMarkup: InlineKeyboardMarkup): Promise<void> { private async updateKeyboard(replyMarkup: InlineKeyboardMarkup): Promise<void> {
@@ -122,8 +105,7 @@ export class TelegramStreamMessage {
} }
); );
} catch (e) { } catch (e) {
const message = e instanceof Error ? e.message : String(e); if (!this.isMessageNotModified(e)) logError(e);
if (!this.isMessageNotModified(message)) logError(e instanceof Error ? e : message);
} }
} }
@@ -184,8 +166,7 @@ export class TelegramStreamMessage {
this.startFlushTimer(); this.startFlushTimer();
return this.waitMessage; return this.waitMessage;
} catch (e) { } catch (e) {
const message = e instanceof Error ? e.message : String(e); if (this.isMessageNotModified(e)) {
if (this.isMessageNotModified(message)) {
this.lastSent = rawText; this.lastSent = rawText;
await this.updateKeyboard(this.keyboard()); await this.updateKeyboard(this.keyboard());
await this.store(); await this.store();
@@ -193,7 +174,7 @@ export class TelegramStreamMessage {
return this.waitMessage; return this.waitMessage;
} }
logError(e instanceof Error ? e : message); logError(e);
this.waitMessage = null; this.waitMessage = null;
this.mediaMode = false; this.mediaMode = false;
} }
@@ -239,44 +220,6 @@ export class TelegramStreamMessage {
return this.text; return this.text;
} }
recordToolExecution(record: TelegramToolExecutionRecord): void {
this.toolExecutions.push(record);
recordToolCall();
logger.debug("tool.execution.recorded", {
requestId: this.cancelRequestId,
toolName: record.toolName,
callId: record.callId,
resultChars: record.resultChars,
});
}
getToolExecutions(): TelegramToolExecutionRecord[] {
return [...this.toolExecutions];
}
recordOutputAttachment(record: TelegramOutputAttachmentRecord): void {
this.outputAttachments.push(record);
logger.debug("output_attachment.recorded", {
requestId: this.cancelRequestId,
artifactKind: record.artifactKind,
fileName: record.fileName,
sizeBytes: record.sizeBytes,
messageId: record.messageId,
});
}
getOutputAttachments(): TelegramOutputAttachmentRecord[] {
return [...this.outputAttachments];
}
sourceChatId(): number {
return this.sourceMessage.chat.id;
}
sourceMessageId(): number {
return this.sourceMessage.message_id;
}
async flush(replyMarkup: InlineKeyboardMarkup | null = this.keyboard(), end?: boolean): Promise<void> { async flush(replyMarkup: InlineKeyboardMarkup | null = this.keyboard(), end?: boolean): Promise<void> {
return this.enqueueEdit(() => this.flushUnsafe(replyMarkup, end)); return this.enqueueEdit(() => this.flushUnsafe(replyMarkup, end));
} }
@@ -349,14 +292,13 @@ export class TelegramStreamMessage {
} }
if (shouldRemoveKeyboard) await this.removeKeyboard(); if (shouldRemoveKeyboard) await this.removeKeyboard();
this.lastSent = next; this.lastSent = next;
} catch (e) { } catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e); if (shouldRemoveKeyboard && this.isMessageNotModified(e)) {
if (shouldRemoveKeyboard && this.isMessageNotModified(message)) {
await this.removeKeyboard(); await this.removeKeyboard();
this.lastSent = next; this.lastSent = next;
return; return;
} }
if (!this.isMessageNotModified(message)) logError(e instanceof Error ? e : message); if (!this.isMessageNotModified(e)) logError(e);
} }
} }
@@ -371,27 +313,22 @@ export class TelegramStreamMessage {
await this.store(); await this.store();
} }
async showImage(image: Buffer, attachment?: StoredAttachment): Promise<void> { async showImage(image: Buffer): Promise<void> {
return this.enqueueEdit(() => this.showImageUnsafe(image, attachment)); return this.enqueueEdit(() => this.showImageUnsafe(image));
} }
async sendArtifact(file: TelegramArtifactFile): Promise<Message | null> { async sendArtifact(file: TelegramArtifactFile): Promise<Message | null> {
return this.enqueueEdit(() => this.sendArtifactUnsafe(file)); return this.enqueueEdit(() => this.sendArtifactUnsafe(file));
} }
private async showImageUnsafe(image: Buffer, attachment?: StoredAttachment): Promise<void> { private async showImageUnsafe(image: Buffer): Promise<void> {
if (this.cancelled) return; if (this.cancelled) return;
const next = this.visibleCaption(); const next = this.visibleCaption();
const useDocument = this.sendImagesAsDocuments;
if (!this.waitMessage) { if (!this.waitMessage) {
if (this.stream) return; if (this.stream) return;
const upload = useDocument ? this.createImageUpload(image, attachment) : null; this.waitMessage = await enqueueTelegramApiCall(
try {
this.waitMessage = useDocument
? await this.sendImageAsDocument(upload!, next)
: await enqueueTelegramApiCall(
() => bot.sendPhoto({ () => bot.sendPhoto({
chat_id: this.sourceMessage.chat.id, chat_id: this.sourceMessage.chat.id,
photo: image, photo: image,
@@ -405,29 +342,17 @@ export class TelegramStreamMessage {
chatType: this.sourceMessage.chat.type, chatType: this.sourceMessage.chat.type,
} }
); );
} finally {
if (upload) this.destroyUpload(upload);
}
this.mediaMode = true; this.mediaMode = true;
this.lastSent = next; this.lastSent = next;
await this.storeMediaMessage(this.waitMessage, attachment);
return; return;
} }
const upload = useDocument ? this.createImageUpload(image, attachment) : null;
try { try {
const result = await enqueueTelegramApiCall( const result = await enqueueTelegramApiCall(
() => bot.editMessageMedia({ () => bot.editMessageMedia({
chat_id: this.waitMessage!.chat.id, chat_id: this.waitMessage!.chat.id,
message_id: this.waitMessage!.message_id, message_id: this.waitMessage!.message_id,
media: useDocument media: {
? {
type: "document",
media: upload!,
caption: prepareTelegramMarkdownV2(next, {mode: "final"}),
parse_mode: "MarkdownV2",
}
: {
type: "photo", type: "photo",
media: image, media: image,
caption: prepareTelegramMarkdownV2(next, {mode: "final"}), caption: prepareTelegramMarkdownV2(next, {mode: "final"}),
@@ -444,50 +369,19 @@ export class TelegramStreamMessage {
if (result && result !== true) this.waitMessage = result; if (result && result !== true) this.waitMessage = result;
this.mediaMode = true; this.mediaMode = true;
this.lastSent = next; this.lastSent = next;
await this.storeMediaMessage(this.waitMessage, attachment); } catch (e: unknown) {
} catch (e) {
const message = e instanceof Error ? e.message : String(e); const message = e instanceof Error ? e.message : String(e);
if (useDocument) { if (!message.includes("message is not modified")) logError(e);
try {
this.waitMessage = await this.sendImageAsDocument(upload!, next);
this.mediaMode = true;
this.lastSent = next;
await this.storeMediaMessage(this.waitMessage, attachment);
return;
} catch (fallbackError) {
logError(fallbackError instanceof Error ? fallbackError : String(fallbackError));
} }
} }
if (!message.includes("message is not modified")) logError(e instanceof Error ? e : message);
} finally {
if (upload) this.destroyUpload(upload);
}
}
private async storeMediaMessage(sent: Message | null, attachment?: StoredAttachment): Promise<void> {
if (!sent || !attachment) return;
const stored: StoredMessage = {
chatId: sent.chat.id,
id: sent.message_id,
replyToMessageId: sent.reply_to_message?.message_id ?? this.sourceMessage.message_id,
fromId: sent.from?.id ?? 0,
text: sent.caption ?? this.visibleText(),
date: sent.date ?? Math.floor(Date.now() / 1000),
attachments: [attachment],
};
await MessageStore.put(stored);
}
private async sendArtifactUnsafe(file: TelegramArtifactFile): Promise<Message | null> { private async sendArtifactUnsafe(file: TelegramArtifactFile): Promise<Message | null> {
if (this.cancelled) return null; if (this.cancelled) return null;
if (file.sizeBytes > PIPELINE_ATTACHMENT_LIMIT_BYTES) { if (file.sizeBytes > TELEGRAM_FILE_LIMIT_BYTES) {
throw new Error(Environment.getTelegramFileTooLargeText( throw new Error(Environment.getTelegramFileTooLargeText(
file.fileName, file.fileName,
PIPELINE_ATTACHMENT_LIMIT_BYTES / 1024 / 1024, TELEGRAM_FILE_LIMIT_BYTES / 1024 / 1024,
)); ));
} }
@@ -530,7 +424,7 @@ export class TelegramStreamMessage {
} }
); );
} catch (e) { } catch (e) {
logError(e instanceof Error ? e : String(e)); logError(e);
sent = await this.sendArtifactAsDocument(file, caption); sent = await this.sendArtifactAsDocument(file, caption);
} }
} else { } else {
@@ -538,37 +432,15 @@ export class TelegramStreamMessage {
} }
await this.storeArtifactMessage(sent, file); await this.storeArtifactMessage(sent, file);
this.recordOutputAttachment({
artifactKind: "generated_file",
fileName: file.fileName,
mimeType: file.mimeType,
sizeBytes: file.sizeBytes,
messageId: sent.message_id,
});
return sent; return sent;
} }
private isPhotoArtifact(file: TelegramArtifactFile): boolean { private isPhotoArtifact(file: TelegramArtifactFile): boolean {
if (this.sendImagesAsDocuments) return false;
return file.kind === "image" return file.kind === "image"
&& file.sizeBytes <= TELEGRAM_PHOTO_LIMIT_BYTES && file.sizeBytes <= TELEGRAM_PHOTO_LIMIT_BYTES
&& ["image/jpeg", "image/png", "image/webp"].includes((file.mimeType || "").toLowerCase()); && ["image/jpeg", "image/png", "image/webp"].includes((file.mimeType || "").toLowerCase());
} }
private createImageUpload(image: Buffer, attachment?: StoredAttachment): FileOptions {
if (attachment?.cachePath && fs.existsSync(attachment.cachePath)) {
return new FileOptions(fs.createReadStream(attachment.cachePath), {
filename: attachment.fileName || path.basename(attachment.cachePath),
contentType: attachment.mimeType || "application/octet-stream",
});
}
return new FileOptions(image, {
filename: attachment?.fileName ?? `image_${Date.now()}.png`,
contentType: attachment?.mimeType || "image/png",
});
}
private createArtifactUpload(file: TelegramArtifactFile): FileOptions { private createArtifactUpload(file: TelegramArtifactFile): FileOptions {
return new FileOptions(fs.createReadStream(file.path), { return new FileOptions(fs.createReadStream(file.path), {
filename: file.fileName, filename: file.fileName,
@@ -582,23 +454,6 @@ export class TelegramStreamMessage {
} }
} }
private async sendImageAsDocument(upload: FileOptions, caption: string): Promise<Message> {
return enqueueTelegramApiCall(
() => bot.sendDocument({
chat_id: this.sourceMessage.chat.id,
document: upload,
caption: prepareTelegramMarkdownV2(caption, {mode: "final"}),
parse_mode: "MarkdownV2",
reply_parameters: {message_id: this.sourceMessage.message_id},
}),
{
method: "sendDocument",
chatId: this.sourceMessage.chat.id,
chatType: this.sourceMessage.chat.type,
}
);
}
private async sendArtifactAsDocument(file: TelegramArtifactFile, caption: string): Promise<Message> { private async sendArtifactAsDocument(file: TelegramArtifactFile, caption: string): Promise<Message> {
return enqueueTelegramApiCall( return enqueueTelegramApiCall(
async () => { async () => {
@@ -632,9 +487,6 @@ export class TelegramStreamMessage {
fileName: file.fileName, fileName: file.fileName,
mimeType: file.mimeType, mimeType: file.mimeType,
cachePath: file.path, cachePath: file.path,
sizeBytes: file.sizeBytes,
scope: "bot_output",
artifactKind: "generated_file",
}; };
const stored: StoredMessage = { const stored: StoredMessage = {
@@ -650,44 +502,6 @@ export class TelegramStreamMessage {
await MessageStore.put(stored); await MessageStore.put(stored);
} }
async storeInternalAttachment(attachment: StoredAttachment): Promise<void> {
if (!this.waitMessage) return;
const stored = await MessageStore.get(this.waitMessage.chat.id, this.waitMessage.message_id);
await MessageStore.put({
chatId: this.waitMessage.chat.id,
id: this.waitMessage.message_id,
replyToMessageId: this.waitMessage.reply_to_message?.message_id ?? this.sourceMessage.message_id,
fromId: this.waitMessage.from?.id ?? 0,
text: this.visibleText(),
date: this.waitMessage.date ?? Math.floor(Date.now() / 1000),
attachments: [
...(stored?.attachments ?? []),
attachment,
],
pipelineAudit: stored?.pipelineAudit,
});
}
async storePipelineAudit(events: StoredMessage["pipelineAudit"]): Promise<void> {
if (!this.waitMessage || !events?.length) return;
const stored = await MessageStore.get(this.waitMessage.chat.id, this.waitMessage.message_id);
await MessageStore.put({
chatId: this.waitMessage.chat.id,
id: this.waitMessage.message_id,
replyToMessageId: this.waitMessage.reply_to_message?.message_id ?? this.sourceMessage.message_id,
fromId: this.waitMessage.from?.id ?? 0,
text: this.visibleText(),
date: this.waitMessage.date ?? Math.floor(Date.now() / 1000),
attachments: stored?.attachments,
pipelineAudit: [
...(stored?.pipelineAudit ?? []),
...events,
],
});
}
async finish(removeKeyboard = true): Promise<void> { async finish(removeKeyboard = true): Promise<void> {
if (this.timer) clearInterval(this.timer); if (this.timer) clearInterval(this.timer);
this.timer = null; this.timer = null;
@@ -709,7 +523,7 @@ export class TelegramStreamMessage {
await this.store(); await this.store();
} }
async fail(error: Error | string | object | null | undefined): Promise<void> { async fail(error: unknown): Promise<void> {
if (this.timer) clearInterval(this.timer); if (this.timer) clearInterval(this.timer);
this.timer = null; this.timer = null;
this.status = ""; this.status = "";
@@ -722,7 +536,7 @@ export class TelegramStreamMessage {
try { try {
await MessageStore.put({...this.waitMessage, text: this.visibleText()} as Message); await MessageStore.put({...this.waitMessage, text: this.visibleText()} as Message);
} catch (e) { } catch (e) {
logError(e instanceof Error ? e : String(e)); logError(e);
} }
} }
} }
+143 -29
View File
@@ -7,20 +7,20 @@ import {Environment} from "../common/environment";
import {bot} from "../index"; import {bot} from "../index";
import { import {
getAvailableAiProviderChoices, getAvailableAiProviderChoices,
getProviderChoiceLabel,
normalizeAiProviderChoice, normalizeAiProviderChoice,
resolveEffectiveAiProviderForUser, resolveEffectiveAiProviderForUser,
} from "../common/user-ai-settings"; } from "../common/user-ai-settings";
import {providerDisplayName} from "./provider-aliases";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {MessageStore} from "../common/message-store"; import {MessageStore} from "../common/message-store";
import {StoredAttachment} from "../model/stored-attachment"; import {StoredAttachment} from "../model/stored-attachment";
import {StoredMessage} from "../model/stored-message"; import {StoredMessage} from "../model/stored-message";
import {logError} from "../util/utils"; import {logError} from "../util/utils";
import {SpeechRequest} from "@mistralai/mistralai/models/components"; import {SpeechRequest} from "@mistralai/mistralai/models/components";
import {createMistralClient, createOpenAiClient, resolveAiRuntimeTarget} from "./ai-runtime-target"; import {createGoogleGenAiClient, createMistralClient, createOpenAiClient, resolveAiRuntimeTarget} from "./ai-runtime-target";
import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./user-request-pipeline";
const MAX_TTS_TEXT_CHARS = 4096; const MAX_TTS_TEXT_CHARS = 4096;
const TELEGRAM_FILE_LIMIT_BYTES = 50 * 1024 * 1024;
export type TextToSpeechFormat = "mp3" | "wav" | "flac" | "opus" | "aac" | "pcm"; export type TextToSpeechFormat = "mp3" | "wav" | "flac" | "opus" | "aac" | "pcm";
@@ -54,6 +54,10 @@ function ttsCacheDir(): string {
return path.join(Environment.DATA_PATH, "cache", "audio"); return path.join(Environment.DATA_PATH, "cache", "audio");
} }
function providerName(provider: AiProvider): string {
return getProviderChoiceLabel(provider);
}
function assertText(text: string): string { function assertText(text: string): string {
const normalized = text.trim(); const normalized = text.trim();
if (!normalized) { if (!normalized) {
@@ -72,6 +76,9 @@ export function isTextToSpeechConfigured(provider: AiProvider): boolean {
case AiProvider.OPENAI: case AiProvider.OPENAI:
const openAiTarget = resolveAiRuntimeTarget(provider, "textToSpeech"); const openAiTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
return !!openAiTarget.apiKey && !!openAiTarget.model; return !!openAiTarget.apiKey && !!openAiTarget.model;
case AiProvider.GEMINI:
const geminiTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
return !!geminiTarget.apiKey && !!geminiTarget.model;
case AiProvider.MISTRAL: case AiProvider.MISTRAL:
const mistralTarget = resolveAiRuntimeTarget(provider, "textToSpeech"); const mistralTarget = resolveAiRuntimeTarget(provider, "textToSpeech");
return !!mistralTarget.apiKey && !!mistralTarget.model; return !!mistralTarget.apiKey && !!mistralTarget.model;
@@ -91,11 +98,11 @@ export async function resolveTextToSpeechProviderForUser(
if (explicitProvider) { if (explicitProvider) {
if (!allowedProviders.includes(explicitProvider)) { if (!allowedProviders.includes(explicitProvider)) {
throw new Error(Environment.getProviderNotAvailableForAccessText(providerDisplayName(explicitProvider))); throw new Error(Environment.getProviderNotAvailableForAccessText(providerName(explicitProvider)));
} }
if (!isTextToSpeechConfigured(explicitProvider)) { if (!isTextToSpeechConfigured(explicitProvider)) {
throw new Error(Environment.getProviderTextToSpeechUnsupportedText(providerDisplayName(explicitProvider))); throw new Error(Environment.getProviderTextToSpeechUnsupportedText(providerName(explicitProvider)));
} }
return {provider: explicitProvider, fallback: false}; return {provider: explicitProvider, fallback: false};
@@ -120,6 +127,8 @@ export async function synthesizeSpeech(request: TextToSpeechRequest): Promise<Sy
switch (request.provider) { switch (request.provider) {
case AiProvider.OPENAI: case AiProvider.OPENAI:
return synthesizeOpenAiSpeech(text, request.voice); return synthesizeOpenAiSpeech(text, request.voice);
case AiProvider.GEMINI:
return synthesizeGeminiSpeech(text, request.voice);
case AiProvider.MISTRAL: case AiProvider.MISTRAL:
return synthesizeMistralSpeech(text, request.voice); return synthesizeMistralSpeech(text, request.voice);
case AiProvider.OLLAMA: case AiProvider.OLLAMA:
@@ -162,7 +171,7 @@ async function synthesizeMistralSpeech(text: string, voice?: string): Promise<Sy
if (target.model) request.model = target.model; if (target.model) request.model = target.model;
if (voice || Environment.MISTRAL_TTS_VOICE_ID) request.voiceId = voice || Environment.MISTRAL_TTS_VOICE_ID; if (voice || Environment.MISTRAL_TTS_VOICE_ID) request.voiceId = voice || Environment.MISTRAL_TTS_VOICE_ID;
const response = await mistralAi.audio.speech.complete(request) as {audioData?: string; audio_data?: string}; const response = await mistralAi.audio.speech.complete(request) as unknown as {audioData?: string; audio_data?: string};
const audioData = response?.audioData ?? response?.audio_data; const audioData = response?.audioData ?? response?.audio_data;
if (typeof audioData !== "string" || !audioData.trim()) { if (typeof audioData !== "string" || !audioData.trim()) {
throw new Error(Environment.mistralTtsNoAudioDataText); throw new Error(Environment.mistralTtsNoAudioDataText);
@@ -180,6 +189,130 @@ async function synthesizeMistralSpeech(text: string, voice?: string): Promise<Sy
}); });
} }
async function synthesizeGeminiSpeech(text: string, voice?: string): Promise<SynthesizedSpeech> {
const target = resolveAiRuntimeTarget(AiProvider.GEMINI, "textToSpeech");
const geminiAi = createGoogleGenAiClient(target);
const response = await geminiAi.models.generateContent({
model: target.model,
contents: text,
config: {
responseModalities: ["AUDIO"],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: {
voiceName: voice || Environment.GEMINI_TTS_VOICE,
},
},
},
},
});
const audioPart = findGeminiAudioPart(response);
if (!audioPart) {
throw new Error(Environment.geminiTextToSpeechUnsupportedText);
}
const decoded = decodeGeminiAudio(audioPart.data, audioPart.mimeType);
return writeSpeechFile({
provider: AiProvider.GEMINI,
model: target.model,
voice: voice || Environment.GEMINI_TTS_VOICE,
buffer: decoded.buffer,
format: decoded.format,
mimeType: decoded.mimeType,
});
}
function findGeminiAudioPart(value: unknown): { data: string; mimeType?: string } | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const inlineData = record.inlineData ?? record.inline_data;
if (inlineData && typeof inlineData === "object") {
const inlineRecord = inlineData as Record<string, unknown>;
const data = inlineRecord.data;
const mimeType = inlineRecord.mimeType ?? inlineRecord.mime_type;
if (typeof data === "string" && (!mimeType || String(mimeType).startsWith("audio/"))) {
return {data, mimeType: typeof mimeType === "string" ? mimeType : undefined};
}
}
for (const child of Object.values(record)) {
if (Array.isArray(child)) {
for (const item of child) {
const found = findGeminiAudioPart(item);
if (found) return found;
}
} else if (child && typeof child === "object") {
const found = findGeminiAudioPart(child);
if (found) return found;
}
}
return null;
}
function decodeGeminiAudio(data: string, mimeType = "audio/wav"): {
buffer: Buffer;
format: TextToSpeechFormat;
mimeType: string;
} {
const normalizedMime = mimeType.toLowerCase();
const raw = Buffer.from(data, "base64");
if (normalizedMime.includes("mpeg") || normalizedMime.includes("mp3")) {
return {buffer: raw, format: "mp3", mimeType: "audio/mpeg"};
}
if (normalizedMime.includes("wav") || raw.subarray(0, 4).toString("ascii") === "RIFF") {
return {buffer: raw, format: "wav", mimeType: "audio/wav"};
}
if (normalizedMime.includes("flac")) {
return {buffer: raw, format: "flac", mimeType: "audio/flac"};
}
if (normalizedMime.includes("opus")) {
return {buffer: raw, format: "opus", mimeType: "audio/opus"};
}
if (normalizedMime.includes("aac")) {
return {buffer: raw, format: "aac", mimeType: "audio/aac"};
}
const sampleRate = Number(/rate=(\d+)/i.exec(mimeType)?.[1]) || 24_000;
return {
buffer: wrapPcm16InWav(raw, sampleRate, 1),
format: "wav",
mimeType: "audio/wav",
};
}
function wrapPcm16InWav(pcm: Buffer, sampleRate: number, channels: number): Buffer {
const bitsPerSample = 16;
const byteRate = sampleRate * channels * bitsPerSample / 8;
const blockAlign = channels * bitsPerSample / 8;
const header = Buffer.alloc(44);
header.write("RIFF", 0);
header.writeUInt32LE(36 + pcm.length, 4);
header.write("WAVE", 8);
header.write("fmt ", 12);
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20);
header.writeUInt16LE(channels, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(byteRate, 28);
header.writeUInt16LE(blockAlign, 32);
header.writeUInt16LE(bitsPerSample, 34);
header.write("data", 36);
header.writeUInt32LE(pcm.length, 40);
return Buffer.concat([header, pcm]);
}
function writeSpeechFile(params: SpeechFileParams): SynthesizedSpeech { function writeSpeechFile(params: SpeechFileParams): SynthesizedSpeech {
fs.mkdirSync(ttsCacheDir(), {recursive: true}); fs.mkdirSync(ttsCacheDir(), {recursive: true});
@@ -213,11 +346,11 @@ function destroyUpload(upload: FileOptions): void {
} }
export async function sendSynthesizedSpeech(sourceMessage: Message, speech: SynthesizedSpeech): Promise<Message> { export async function sendSynthesizedSpeech(sourceMessage: Message, speech: SynthesizedSpeech): Promise<Message> {
if (speech.sizeBytes > PIPELINE_ATTACHMENT_LIMIT_BYTES) { if (speech.sizeBytes > TELEGRAM_FILE_LIMIT_BYTES) {
throw new Error(Environment.speechFileTooLargeText); throw new Error(Environment.speechFileTooLargeText);
} }
const caption = Environment.getTextToSpeechCaption(providerDisplayName(speech.provider), speech.model, speech.voice); const caption = Environment.getTextToSpeechCaption(providerName(speech.provider), speech.model, speech.voice);
await enqueueTelegramApiCall( await enqueueTelegramApiCall(
() => bot.sendChatAction({ () => bot.sendChatAction({
@@ -241,13 +374,13 @@ export async function sendSynthesizedSpeech(sourceMessage: Message, speech: Synt
reply_parameters: {message_id: sourceMessage.message_id}, reply_parameters: {message_id: sourceMessage.message_id},
}); });
} finally { } finally {
// destroyUpload(upload); destroyUpload(upload);
} }
}, },
{method: "sendVoice", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type} {method: "sendVoice", chatId: sourceMessage.chat.id, chatType: sourceMessage.chat.type}
); );
} catch (e) { } catch (e) {
logError(e instanceof Error ? e : String(e)); logError(e);
sent = await sendSpeechDocument(sourceMessage, speech, caption); sent = await sendSpeechDocument(sourceMessage, speech, caption);
} }
} else { } else {
@@ -258,16 +391,6 @@ export async function sendSynthesizedSpeech(sourceMessage: Message, speech: Synt
return sent; return sent;
} }
export function speechToOutputAttachmentRecord(speech: SynthesizedSpeech, messageId?: number) {
return {
artifactKind: "tts_audio" as const,
fileName: speech.fileName,
mimeType: speech.mimeType,
sizeBytes: speech.sizeBytes,
messageId,
};
}
async function sendSpeechDocument(sourceMessage: Message, speech: SynthesizedSpeech, caption: string): Promise<Message> { async function sendSpeechDocument(sourceMessage: Message, speech: SynthesizedSpeech, caption: string): Promise<Message> {
return enqueueTelegramApiCall( return enqueueTelegramApiCall(
async () => { async () => {
@@ -296,15 +419,6 @@ async function storeSpeechMessage(sent: Message, sourceMessage: Message, speech:
fileName: speech.fileName, fileName: speech.fileName,
mimeType: speech.mimeType, mimeType: speech.mimeType,
cachePath: speech.path, cachePath: speech.path,
sizeBytes: speech.sizeBytes,
scope: "bot_output",
artifactKind: "tts_audio",
metadata: {
provider: speech.provider,
model: speech.model,
voice: speech.voice,
format: speech.format,
},
}; };
const stored: StoredMessage = { const stored: StoredMessage = {
-28
View File
@@ -1,28 +0,0 @@
import type {AiProviderAdapter} from "./provider-adapters.js";
import {executeToolBatch, type ToolCallData, type ToolExecutionMemory} from "./unified-ai-runner.shared.js";
import type {TelegramStreamMessage} from "./telegram-stream-message.js";
import type {ToolRuntimeContext} from "./tools/runtime.js";
export async function executeToolBatchWithAdapter(params: {
userId: number | undefined | null;
toolCalls: ToolCallData[];
streamMessage: TelegramStreamMessage;
toolContext: ToolRuntimeContext;
toolMemory: ToolExecutionMemory;
adapter: AiProviderAdapter;
appendTargets?: unknown[][];
}): Promise<string[]> {
const results = await executeToolBatch(
params.userId,
params.toolCalls,
params.streamMessage,
params.toolContext,
params.toolMemory,
);
for (const target of params.appendTargets ?? []) {
params.adapter.appendToolResults(target, params.toolCalls, results);
}
return results;
}
-39
View File
@@ -1,39 +0,0 @@
import type {StoredAttachment} from "../model/stored-attachment";
import type {TelegramOutputAttachmentRecord, TelegramToolExecutionRecord} from "./telegram-stream-message.js";
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
export async function persistToolLoopSummaryArtifactAttachment(params: {
chatId: number;
messageId: number;
text: string;
executions: readonly TelegramToolExecutionRecord[];
outputAttachments: readonly TelegramOutputAttachmentRecord[];
}): Promise<StoredAttachment | undefined> {
if (!params.executions.length) return undefined;
return await persistInternalJsonArtifactAttachment({
artifactKind: "tool_result",
fileNamePrefix: "tool-loop-summary",
chatId: params.chatId,
messageId: params.messageId,
payload: {
stage: "tool_loop",
text: params.text.trim(),
executions: params.executions.map(execution => ({
toolName: execution.toolName,
callId: execution.callId,
argumentsText: execution.argumentsText,
resultChars: execution.resultChars,
startedAt: execution.startedAt,
finishedAt: execution.finishedAt,
})),
outputAttachments: params.outputAttachments,
},
metadata: {
stage: "tool_loop",
toolExecutions: params.executions.length,
outputAttachments: params.outputAttachments.length,
textChars: params.text.trim().length,
},
});
}
-38
View File
@@ -1,38 +0,0 @@
import type {ToolCallData} from "./unified-ai-runner.shared.js";
export type ToolLoopStopReason = "no_tool_calls" | "max_rounds_reached";
export type ToolLoopContinuation = {
continue: boolean;
reason?: ToolLoopStopReason;
remainingRounds: number;
};
export function decideToolLoopContinuation(params: {
round: number;
maxRounds: number;
toolCalls: readonly ToolCallData[];
}): ToolLoopContinuation {
const remainingRounds = Math.max(params.maxRounds - params.round - 1, 0);
if (!params.toolCalls.length) {
return {
continue: false,
reason: "no_tool_calls",
remainingRounds,
};
}
if (remainingRounds === 0) {
return {
continue: false,
reason: "max_rounds_reached",
remainingRounds,
};
}
return {
continue: true,
remainingRounds,
};
}
-22
View File
@@ -1,22 +0,0 @@
export type ToolLoopRoundOutcome = {
shouldContinue: boolean;
maxRoundsReached?: boolean;
};
export async function runToolLoopRounds(params: {
maxRounds: number;
onRound: (round: number) => Promise<ToolLoopRoundOutcome>;
onMaxRoundsReached?: (round: number) => Promise<void> | void;
}): Promise<void> {
for (let round = 0; round < params.maxRounds; round++) {
const outcome = await params.onRound(round);
if (!outcome.shouldContinue) {
if (outcome.maxRoundsReached) {
await params.onMaxRoundsReached?.(round);
}
return;
}
}
await params.onMaxRoundsReached?.(params.maxRounds - 1);
}
-56
View File
@@ -1,56 +0,0 @@
import type {PipelineArtifact} from "./user-request-pipeline/types.js";
import type {TelegramOutputAttachmentRecord, TelegramToolExecutionRecord} from "./telegram-stream-message.js";
import {summarizeModelOutput} from "./response-model-output.js";
export type ToolLoopSummary = {
status: "succeeded" | "skipped";
fallbackAction?: "continue_without_stage";
details: {
modelOutput: ReturnType<typeof summarizeModelOutput>;
count: number;
tools: Array<{
toolName: string;
callId: string;
resultChars: number;
}>;
};
artifacts?: PipelineArtifact[];
};
export function summarizeToolLoop(params: {
text: string;
executions: readonly TelegramToolExecutionRecord[];
outputAttachments: readonly TelegramOutputAttachmentRecord[];
}): ToolLoopSummary {
const count = params.executions.length;
const tools = params.executions.map(execution => ({
toolName: execution.toolName,
callId: execution.callId,
resultChars: execution.resultChars,
}));
return {
status: count ? "succeeded" : "skipped",
fallbackAction: count ? undefined : "continue_without_stage",
details: {
modelOutput: summarizeModelOutput({
text: params.text,
toolExecutions: params.executions,
outputAttachments: params.outputAttachments,
}),
count,
tools,
},
artifacts: count ? [{
kind: "tool_result",
stage: "tool_loop",
createdAt: new Date().toISOString(),
toolName: "summary",
callId: "tool_loop_summary",
resultText: JSON.stringify({
count,
tools,
}),
}] : undefined,
};
}
+36 -68
View File
@@ -1,56 +1,30 @@
import {AiTool} from "./tool-types"; import {AiTool} from "./tool-types";
import {AiProvider} from "../model/ai-provider.js"; import {AiProvider} from "../model/ai-provider";
import {getTools} from "./tools/registry.js"; import {getTools} from "./tools/registry";
import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search.js";
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator.js";
import {toolSchemaNames} from "./tool-schema-utils.js";
export type AiProviderName = "ollama" | "openai" | "mistral"; export type AiProviderName = "ollama" | "openai" | "gemini" | "mistral";
export function getOllamaTools(forCreator?: boolean): AiTool[] { export function getOllamaTools(): AiTool[] {
return getTools(forCreator); return getTools();
} }
const openAiForbiddenTools = [ export function getOpenAITools(): AiTool[] {
WEB_SEARCH_TOOL_NAME, return getTools().map(tool => ({
PYTHON_INTERPRETER_TOOL_NAME
];
function allowedOpenAiTool(tool: AiTool): boolean {
return !openAiForbiddenTools.includes(tool.function.name);
}
export function getOpenAITools(forCreator?: boolean): AiTool[] {
return getTools(forCreator).filter(allowedOpenAiTool).map(tool => ({
type: "function", type: "function",
function: tool.function, function: tool.function,
})); }));
} }
export function getOpenAICompatibleTools(forCreator?: boolean): AiTool[] {
// The compatible chat.completions backend only accepts plain function tools.
return getOpenAITools(forCreator);
}
export type OpenAiResponseTool = { export type OpenAiResponseTool = {
type: "function"; type: "function";
name: string; name: string;
description?: string; description?: string;
parameters?: object; parameters?: unknown;
strict: false; strict: false;
}; };
export type OpenAiCodeInterpreterTool = { export function getOpenAIResponsesTools(): OpenAiResponseTool[] {
type: "code_interpreter"; return getTools().map(tool => ({
container: {
type: "auto";
file_ids?: string[];
memory_limit?: "1g" | "4g" | "16g" | "64g" | null;
} | string;
};
export function getOpenAIResponsesTools(forCreator?: boolean): OpenAiResponseTool[] {
return getOpenAITools(forCreator).map(tool => ({
type: "function", type: "function",
name: tool.function.name, name: tool.function.name,
description: tool.function.description, description: tool.function.description,
@@ -59,46 +33,40 @@ export function getOpenAIResponsesTools(forCreator?: boolean): OpenAiResponseToo
})); }));
} }
export function getOpenAICodeInterpreterTool(): OpenAiCodeInterpreterTool { export function getMistralTools(): AiTool[] {
return { return getTools().map(tool => ({
type: "code_interpreter",
container: {
type: "auto",
},
};
}
export function getMistralTools(forCreator?: boolean): AiTool[] {
return getTools(forCreator).map(tool => ({
type: "function", type: "function",
function: tool.function, function: tool.function,
})); }));
} }
export function getProviderTools(provider: AiProvider, forCreator?: boolean): AiTool[] { export type GeminiTool = {
functionDeclarations: Array<{
name: string;
description?: string;
parametersJsonSchema?: unknown;
}>;
}
export function getGeminiTools(): GeminiTool[] {
const functionDeclarations = getTools().map(tool => ({
name: tool.function.name,
description: tool.function.description,
parametersJsonSchema: tool.function.parameters,
}));
return functionDeclarations.length ? [{functionDeclarations}] : [];
}
export function getProviderTools(provider: AiProvider): AiTool[] {
switch (provider) { switch (provider) {
case AiProvider.OLLAMA: case AiProvider.OLLAMA:
return getOllamaTools(forCreator); return getOllamaTools();
case AiProvider.GEMINI:
return getTools();
case AiProvider.MISTRAL: case AiProvider.MISTRAL:
return getMistralTools(forCreator); return getMistralTools();
case AiProvider.OPENAI: case AiProvider.OPENAI:
return getOpenAITools(forCreator); return getOpenAITools();
} }
} }
export function ensureToolsSelected<T>(availableTools: readonly T[], selectedTools: readonly T[], toolNames: readonly string[]): T[] {
const selected = [...selectedTools];
const selectedNames = new Set(selected.flatMap(tool => toolSchemaNames(tool as never)));
for (const toolName of toolNames) {
if (selectedNames.has(toolName)) continue;
const extraTool = availableTools.find(tool => toolSchemaNames(tool as never).includes(toolName));
if (extraTool) {
selected.unshift(extraTool);
selectedNames.add(toolName);
}
}
return selected;
}
-146
View File
@@ -1,146 +0,0 @@
import {AiProvider} from "../model/ai-provider.js";
import type {BoundaryValue} from "../common/boundary-types.js";
import type {TelegramStreamMessage} from "./telegram-stream-message.js";
import type {RuntimeConfigSnapshot} from "./unified-ai-runner.shared.js";
import {allToolSchemaNames, toolSchemaNames} from "./tool-schema-utils.js";
import type {ToolRanker} from "./unified-ai-runner.tool-ranker.js";
import type {PipelineAuditEvent} from "./user-request-pipeline/types.js";
function latestUserText(messages: readonly { role?: string; content?: unknown }[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message?.role !== "user") continue;
if (typeof message.content === "string") return message.content;
if (Array.isArray(message.content)) {
return message.content
.map(part => typeof part === "object" && part !== null && "text" in part && typeof (part as { text?: unknown }).text === "string"
? (part as { text: string }).text
: "")
.filter(Boolean)
.join("\n");
}
}
return "";
}
export async function runToolRankStage(params: {
provider: AiProvider;
model: string;
round: number;
config: RuntimeConfigSnapshot;
availableTools: readonly BoundaryValue[];
messages: readonly { role?: string; content?: unknown }[];
streamMessage: TelegramStreamMessage;
signal: AbortSignal;
toolRanker?: ToolRanker;
storeAudit?: (params: {
streamMessage: TelegramStreamMessage;
provider: AiProvider;
model: string;
round: number;
startedAt: number;
startedAtIso: string;
availableTools: string[];
selectedTools?: string[];
usedRanker?: boolean;
error?: unknown;
}) => Promise<void>;
}): Promise<{
filteredTools: BoundaryValue[];
selectedToolNames: string[];
usedRanker: boolean;
}> {
const toolRanker = params.toolRanker ?? new (await import("./unified-ai-runner.tool-ranker.js")).ToolRanker(params.config);
const startedAt = Date.now();
const startedAtIso = new Date().toISOString();
const filterSelectedTools = (selectedToolNames: readonly string[]): BoundaryValue[] => {
const selected = new Set(selectedToolNames);
return params.availableTools.filter(tool => toolSchemaNames(tool).some(name => selected.has(name)));
};
const storeAudit = params.storeAudit ?? (async (auditParams: {
streamMessage: TelegramStreamMessage;
provider: AiProvider;
model: string;
round: number;
startedAt: number;
startedAtIso: string;
availableTools: string[];
selectedTools?: string[];
usedRanker?: boolean;
error?: unknown;
}) => {
const event: PipelineAuditEvent = {
stage: "tool_rank",
status: auditParams.error ? "failed" : "succeeded",
startedAt: auditParams.startedAtIso,
finishedAt: new Date().toISOString(),
durationMs: Date.now() - auditParams.startedAt,
provider: auditParams.provider,
model: auditParams.model,
details: {
round: auditParams.round,
availableTools: auditParams.availableTools,
selectedTools: auditParams.selectedTools ?? [],
usedRanker: auditParams.usedRanker ?? false,
toolRankDecision: {
provider: auditParams.provider,
round: auditParams.round,
availableTools: auditParams.availableTools,
selectedTools: auditParams.selectedTools ?? [],
usedRanker: auditParams.usedRanker ?? false,
},
},
error: auditParams.error instanceof Error ? auditParams.error.message : auditParams.error ? String(auditParams.error) : undefined,
};
await auditParams.streamMessage.storePipelineAudit([event]);
});
params.streamMessage.setStatus("🧩 Выбираю подходящие инструменты...");
await params.streamMessage.flush();
try {
const selection = await toolRanker.selectTools({
provider: params.provider,
userQuery: latestUserText(params.messages),
availableTools: params.availableTools,
round: params.round,
signal: params.signal,
});
params.streamMessage.clearStatus();
await params.streamMessage.flush();
await storeAudit({
streamMessage: params.streamMessage,
provider: params.provider,
model: params.model,
round: params.round,
startedAt,
startedAtIso,
availableTools: allToolSchemaNames(params.availableTools),
selectedTools: selection.toolNames,
usedRanker: selection.usedRanker,
});
return {
filteredTools: filterSelectedTools(selection.toolNames),
selectedToolNames: selection.toolNames,
usedRanker: selection.usedRanker,
};
} catch (error) {
params.streamMessage.clearStatus();
await params.streamMessage.flush();
await storeAudit({
streamMessage: params.streamMessage,
provider: params.provider,
model: params.model,
round: params.round,
startedAt,
startedAtIso,
availableTools: allToolSchemaNames(params.availableTools),
error,
});
throw error;
}
}
-56
View File
@@ -1,56 +0,0 @@
import {ToolRankerFallbackPolicy} from "../common/policies.js";
import {decidePipelineFallback, type PipelineFallbackDecision} from "./user-request-pipeline/fallback-executor.js";
export type ToolRankerFallbackSelection = {
toolNames: string[];
usedRanker: boolean;
};
export type ToolRankerFallbackDecision = PipelineFallbackDecision & ToolRankerFallbackSelection;
function fallbackActionForPolicy(policy: ToolRankerFallbackPolicy) {
return policy === ToolRankerFallbackPolicy.MAIN_MODEL
? "use_alternate_target"
: "continue_without_stage";
}
export function decideToolRankerFallback(params: {
fallbackPolicy: ToolRankerFallbackPolicy;
availableToolNames: readonly string[];
reason: "unavailable" | "failed";
}): ToolRankerFallbackDecision {
const action = fallbackActionForPolicy(params.fallbackPolicy);
const decision = decidePipelineFallback({
stage: "tool_rank",
reason: params.reason,
policies: [{
stage: "tool_rank",
onUnavailable: action,
onFailed: action,
}],
});
return {
...decision,
toolNames: params.fallbackPolicy === ToolRankerFallbackPolicy.NO_TOOLS
? []
: [...params.availableToolNames],
usedRanker: false,
};
}
export function resolveToolRankerFallbackSelection(params: {
fallbackPolicy: ToolRankerFallbackPolicy;
availableToolNames: readonly string[];
}): ToolRankerFallbackSelection {
const decision = decideToolRankerFallback({
fallbackPolicy: params.fallbackPolicy,
availableToolNames: params.availableToolNames,
reason: "failed",
});
return {
toolNames: decision.toolNames,
usedRanker: decision.usedRanker,
};
}
-669
View File
@@ -1,669 +0,0 @@
import type {BoundaryValue} from "../common/boundary-types";
export type ToolRankerExample = {
user: string;
toolNames: string[];
note?: string;
};
export type ToolRankerToolInfo = {
name: string;
description: string;
rankerHint: string;
examples?: ToolRankerExample[];
};
const tool = (
name: string,
description: string,
rankerHint: string,
examples: ToolRankerExample[] = [],
): ToolRankerToolInfo => ({
name,
description,
rankerHint,
examples: examples.length ? examples : undefined,
});
const example = (user: string, toolNames: string[], note?: string): ToolRankerExample => ({
user,
toolNames,
note,
});
export const TOOL_RANKER_TOOL_INFOS = {
no_tool: tool(
"no_tool",
"No tool action is needed.",
"Use for normal answers, explanations, advice, planning, code writing without execution, rewriting, translation, and general conversation.",
[
example("объясни docker volumes", ["no_tool"]),
example("напиши промпт для Claude", ["no_tool"]),
example("как лучше спроектировать эту архитектуру?", ["no_tool"]),
],
),
get_datetime: tool(
"get_datetime",
"Get the current date, time, or timezone-aware moment.",
"Use for current date/time, today/tomorrow/yesterday, timezone-aware time, and calculations based on the current moment.",
[
example("какое сегодня число?", ["get_datetime"]),
example("который час?", ["get_datetime"]),
example("что будет через 10 дней?", ["get_datetime"]),
],
),
get_financial_market_data: tool(
"get_financial_market_data",
"Get current market, price, currency, or ticker data.",
"Use for current/recent stocks, crypto, fiat exchange rates, commodities, indices, futures, and market prices.",
[
example("сколько сейчас BTC?", ["get_financial_market_data"]),
example("курс USD/RUB", ["get_financial_market_data"]),
example("цена золота сейчас", ["get_financial_market_data"]),
],
),
get_weather: tool(
"get_weather",
"Get current weather or forecast data.",
"Use for weather, rain, snow, wind, temperature, forecast, and weather-dependent planning.",
[
example("погода завтра", ["get_weather"]),
example("будет дождь сегодня?", ["get_weather"]),
example("можно сегодня на велике?", ["get_weather"]),
],
),
read_file: tool(
"read_file",
"Read a known local file path.",
"Use when the user asks to read, open, inspect, or summarize a known local file path.",
[
example("прочитай src/index.ts", ["read_file"]),
example("посмотри package.json", ["read_file"]),
example("открой этот файл", ["read_file"]),
],
),
list_directory: tool(
"list_directory",
"List files or folders in a local path.",
"Use when the user asks to list files/folders, inspect a directory, show project structure, or see what exists in a path.",
[
example("покажи структуру проекта", ["list_directory"]),
example("что лежит в src?", ["list_directory"]),
example("выведи список файлов", ["list_directory"]),
],
),
search_files: tool(
"search_files",
"Search local files by name, content, symbol, or keyword.",
"Use when the exact file path is unknown and the user wants to find files, usages, TODOs, symbols, classes, functions, or error messages.",
[
example("найди где используется sendMessage", ["search_files"]),
example("найди все TODO", ["search_files"]),
example("где определён BotService?", ["search_files"]),
],
),
read_user_info: tool(
"read_user_info",
"Read persistent user memory from user.md.",
"Use before editing or when the user asks what you remember about them.",
[
example("что ты помнишь обо мне?", ["read_user_info"]),
example("покажи мою память", ["read_user_info"]),
],
),
read_system_info: tool(
"read_system_info",
"Read persistent assistant memory from system.md.",
"Use before editing or when the user asks what instructions you remember about yourself.",
[
example("что ты помнишь о себе?", ["read_system_info"]),
example("покажи память о тебе", ["read_system_info"]),
],
),
add_user_info: tool(
"add_user_info",
"Append a durable fact about the user to persistent memory.",
"Use when the user asks to remember a new fact, preference, identity detail, or profile information about themselves.",
[
example("запомни, что меня зовут Иван", ["add_user_info"]),
example("запомни, что я люблю чай", ["add_user_info"]),
example("remember that I like short answers", ["add_user_info"]),
],
),
add_system_info: tool(
"add_system_info",
"Append a durable instruction about the assistant to persistent memory.",
"Use when the user asks to remember a new assistant identity, style, or behavior instruction.",
[
example("тебя зовут Евлампий", ["add_system_info"]),
example("ты ИИ помощник", ["add_system_info"]),
example("remember you are a concise assistant", ["add_system_info"]),
],
),
remove_user_info: tool(
"remove_user_info",
"Remove a specific user fact from persistent memory.",
"Use when the user asks to forget, delete, or remove a specific fact about themselves.",
[
example("забудь, что я люблю кофе", ["remove_user_info"]),
example("удали из памяти, что я живу в Москве", ["remove_user_info"]),
example("forget that I work at ACME", ["remove_user_info"]),
],
),
remove_system_info: tool(
"remove_system_info",
"Remove a specific assistant instruction from persistent memory.",
"Use when the user asks to forget or remove a specific instruction about the assistant.",
[
example("забудь, что тебя зовут Евлампий", ["remove_system_info"]),
example("убери правило отвечать коротко", ["remove_system_info"]),
example("forget that you are a concise assistant", ["remove_system_info"]),
],
),
replace_user_info: tool(
"replace_user_info",
"Replace the full user memory with a new compact version.",
"Use when the user wants to overwrite all remembered user info, for example when they say to forget everything and keep only the new fact.",
[
example("забудь всё обо мне и запиши только это: меня зовут Иван", ["replace_user_info"]),
example("замени всю память обо мне на: люблю чай и короткие ответы", ["replace_user_info"]),
],
),
replace_system_info: tool(
"replace_system_info",
"Replace the full assistant memory with a new compact version.",
"Use when the user wants to overwrite all remembered assistant info or instructions.",
[
example("забудь всё о себе и запиши только это: тебя зовут Евлампий", ["replace_system_info"]),
example("замени инструкцию о себе на: ты краткий ИИ помощник", ["replace_system_info"]),
],
),
delete_user_info: tool(
"delete_user_info",
"Delete user.md entirely.",
"Use when the user explicitly asks to delete all remembered user info, not just a fragment.",
[
example("удали всю память обо мне", ["delete_user_info"]),
example("forget all user memory", ["delete_user_info"]),
],
),
delete_system_info: tool(
"delete_system_info",
"Delete system.md entirely.",
"Use when the user explicitly asks to delete all remembered assistant info, not just a fragment.",
[
example("удали всю память о себе", ["delete_system_info"]),
example("forget all assistant memory", ["delete_system_info"]),
],
),
create_file: tool(
"create_file",
"Create a new small file.",
"Use when the user asks to create a new file with specific content.",
[
example("создай README.md", ["create_file"]),
example("создай .env.example", ["create_file"]),
example("сделай docker-compose.yml", ["create_file"]),
],
),
update_file: tool(
"update_file",
"Replace an existing file completely.",
"Use only for full file replacement or overwrite.",
[
example("полностью перезапиши config.json", ["update_file"]),
example("замени файл этой версией", ["update_file"]),
example("overwrite this file", ["update_file"]),
],
),
edit_file_patch: tool(
"edit_file_patch",
"Apply a targeted patch to an existing file.",
"Use for targeted edits, patches, diffs, refactors, and changes that should preserve most of the file.",
[
example("исправь этот баг патчем", ["edit_file_patch"]),
example("добавь эту опцию в существующий конфиг", ["edit_file_patch"]),
example("измени только эту функцию", ["edit_file_patch"]),
],
),
create_directory: tool(
"create_directory",
"Create directories or folder trees.",
"Use when the user asks to create folders or directory structures.",
[
example("создай папку src/services", ["create_directory"]),
example("создай структуру директорий", ["create_directory"]),
],
),
copy_path: tool(
"copy_path",
"Copy a file or folder path.",
"Use when the user asks to copy or duplicate a file or folder.",
[
example("скопируй config.example.json в config.json", ["copy_path"]),
example("дублируй эту папку", ["copy_path"]),
],
),
rename_path: tool(
"rename_path",
"Rename or move a file or folder.",
"Use when the user asks to rename or move a file or folder.",
[
example("переименуй файл", ["rename_path"]),
example("перемести notes.md в archive", ["rename_path"]),
],
),
delete_path: tool(
"delete_path",
"Delete a file or folder.",
"Use only when the user clearly asks to delete or remove something.",
[
example("удали папку dist", ["delete_path"]),
example("remove node_modules", ["delete_path"]),
example("delete this file", ["delete_path"]),
],
),
send_file_as_attachment: tool(
"send_file_as_attachment",
"Send a local file as an attachment.",
"Use when the user wants to receive, export, send, attach, or download a local file as an attachment.",
[
example("пришли мне этот файл", ["send_file_as_attachment"]),
example("отправь заметку файлом", ["send_file_as_attachment"]),
example("export this as attachment", ["send_file_as_attachment"]),
],
),
begin_file_write: tool(
"begin_file_write",
"Start a large chunked file write.",
"Use with write_file_chunk and finish_file_write for large file creation or writing.",
[
example("создай большой markdown отчёт", ["begin_file_write", "write_file_chunk", "finish_file_write"]),
example("запиши большой файл чанками", ["begin_file_write", "write_file_chunk", "finish_file_write"]),
],
),
write_file_chunk: tool(
"write_file_chunk",
"Append a chunk to an active large file write.",
"Use together with begin_file_write and finish_file_write for chunked file writing.",
),
finish_file_write: tool(
"finish_file_write",
"Complete an active large file write.",
"Use together with begin_file_write and write_file_chunk to finish chunked file writing.",
),
cancel_file_write: tool(
"cancel_file_write",
"Cancel an active large file write.",
"Use when the user asks to cancel an active file write operation.",
[
example("отмени запись файла", ["cancel_file_write"]),
example("cancel file write", ["cancel_file_write"]),
],
),
shell_execute: tool(
"shell_execute",
"Run shell commands in the workspace environment.",
"Use for terminal commands, tests, builds, docker, git, npm, pnpm, bun, gradle, diagnostics, logs, install commands, or system inspection.",
[
example("запусти npm test", ["shell_execute"]),
example("собери проект", ["shell_execute"]),
example("проверь docker logs", ["shell_execute"]),
],
),
python_interpreter: tool(
"python_interpreter",
"Execute Python code.",
"Use when the user explicitly asks to run Python code, execute Python, calculate with Python, or test a Python script.",
[
example("выполни этот python код", ["python_interpreter"]),
example("посчитай это питоном", ["python_interpreter"]),
example("напиши и запусти python скрипт", ["python_interpreter"]),
],
),
code_interpreter: tool(
"code_interpreter",
"Run sandboxed code and data analysis.",
"Use for sandbox computation, data/file analysis, CSV processing, archive processing, charts, tables, and generated reports.",
[
example("проанализируй CSV", ["code_interpreter"]),
example("построй график", ["code_interpreter"]),
example("обработай архив", ["code_interpreter"]),
],
),
image_generation: tool(
"image_generation",
"Generate or edit an image.",
"Use when the user asks to generate, create, edit, transform, restyle, enhance, remove, add, replace, recolor, upscale, or alter an image.",
[
example("сделай его лысым", ["image_generation"]),
example("убери фон", ["image_generation"]),
example("сделай в стиле аниме", ["image_generation"]),
],
),
web_search: tool(
"web_search",
"Search the public web for current, recent, or external information.",
"Use only for current/recent/public online information, search, verification, links, documentation, comparisons, or external data.",
[
example("найди актуальную документацию OpenAI API", ["web_search"]),
example("проверь, вышел ли Kotlin 2.3", ["web_search"]),
example("какие сейчас цены на VPS?", ["web_search"]),
],
),
file_search: tool(
"file_search",
"Search uploaded documents or indexed vector-store files.",
"Use for uploaded documents, vector stores, PDFs/docs already indexed or attached to the assistant context.",
[
example("найди в моих документах про MCP", ["file_search"]),
example("что в загруженном PDF написано про оплату?", ["file_search"]),
example("поищи в базе знаний", ["file_search"]),
],
),
} as const satisfies Record<string, ToolRankerToolInfo>;
export type ToolRankerToolName = keyof typeof TOOL_RANKER_TOOL_INFOS;
function isString(value: BoundaryValue): value is string {
return typeof value === "string";
}
function normalizeToolNames(names: readonly string[]): string[] {
const unique: string[] = [];
for (const name of names) {
if (!name || unique.includes(name)) {
continue;
}
unique.push(name);
}
return unique;
}
function extractJsonCandidate(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
if (fenced?.[1]) {
return fenced[1].trim();
}
const firstObjectStart = trimmed.indexOf("{");
const lastObjectEnd = trimmed.lastIndexOf("}");
if (firstObjectStart !== -1 && lastObjectEnd !== -1 && lastObjectEnd > firstObjectStart) {
return trimmed.slice(firstObjectStart, lastObjectEnd + 1).trim();
}
const firstArrayStart = trimmed.indexOf("[");
const lastArrayEnd = trimmed.lastIndexOf("]");
if (firstArrayStart !== -1 && lastArrayEnd !== -1 && lastArrayEnd > firstArrayStart) {
return trimmed.slice(firstArrayStart, lastArrayEnd + 1).trim();
}
return trimmed;
}
function parseSelectionValue(value: BoundaryValue): string[] {
if (typeof value === "string") {
return [value];
}
if (Array.isArray(value)) {
return value.filter(isString);
}
if (value !== null && typeof value === "object") {
const rawToolNames = (value as Record<string, BoundaryValue>).toolNames;
return parseSelectionValue(rawToolNames as BoundaryValue);
}
return [];
}
function asOptionalString(value: BoundaryValue): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function isRecord(value: BoundaryValue): value is Record<string, BoundaryValue> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function toolNamesFromTool(tool: BoundaryValue): string[] {
if (!isRecord(tool)) {
return [];
}
const functionValue = isRecord(tool.function) ? tool.function : undefined;
const directName = functionValue?.name ?? tool.name ?? (typeof tool.type === "string" && tool.type !== "function" ? tool.type : undefined);
const name = asOptionalString(directName);
return name ? [name] : [];
}
function fallbackToolInfoFromTool(toolValue: BoundaryValue, name: string): ToolRankerToolInfo | undefined {
if (!isRecord(toolValue)) return undefined;
const fn = isRecord(toolValue.function) ? toolValue.function : undefined;
const description = asOptionalString(fn?.description ?? toolValue.description)
?? `Tool ${name}.`;
return tool(
name,
description,
"Use when the tool description matches the user's request.",
);
}
export function getToolRankerToolInfo(name: string): ToolRankerToolInfo | undefined {
return TOOL_RANKER_TOOL_INFOS[name as ToolRankerToolName];
}
export function getToolRankerToolInfos(names: readonly string[]): ToolRankerToolInfo[] {
return normalizeToolNames(names)
.map(name => getToolRankerToolInfo(name))
.filter((tool): tool is ToolRankerToolInfo => !!tool);
}
export function getToolRankerAvailableToolInfos(availableTools: readonly BoundaryValue[]): ToolRankerToolInfo[] {
const infos = new Map<string, ToolRankerToolInfo>();
infos.set("no_tool", TOOL_RANKER_TOOL_INFOS.no_tool);
for (const tool of availableTools) {
for (const name of toolNamesFromTool(tool)) {
if (infos.has(name)) continue;
const known = getToolRankerToolInfo(name);
const fallback = fallbackToolInfoFromTool(tool, name);
if (known) {
infos.set(name, known);
} else if (fallback) {
infos.set(name, fallback);
}
}
}
return [...infos.values()];
}
function renderToolLine(tool: ToolRankerToolInfo, compact: boolean): string {
if (compact) {
return `- ${tool.name}: ${tool.rankerHint}`;
}
return `- ${tool.name}: ${tool.description}\n ${tool.rankerHint}`;
}
function renderExamples(tool: ToolRankerToolInfo, maxExamplesPerTool: number): string[] {
if (!tool.examples?.length || maxExamplesPerTool <= 0) {
return [];
}
return tool.examples.slice(0, maxExamplesPerTool).flatMap(example => {
const lines = [
`User: ${JSON.stringify(example.user)}`,
];
if (example.note?.trim()) {
lines.push(`Note: ${example.note.trim()}`);
}
lines.push(JSON.stringify({toolNames: example.toolNames}));
return lines;
});
}
function buildPriorityLines(tools: readonly ToolRankerToolInfo[]): string[] {
const names = new Set(tools.map(tool => tool.name));
const lines: string[] = [];
const pushIfAvailable = (name: string, line: string): void => {
if (names.has(name)) {
lines.push(`- ${line}`);
}
};
pushIfAvailable("get_datetime", "current date/time -> get_datetime");
pushIfAvailable("get_financial_market_data", "market prices, currency, crypto, stocks -> get_financial_market_data");
pushIfAvailable("get_weather", "weather or forecast -> get_weather");
pushIfAvailable("image_generation", "image creation or editing -> image_generation");
pushIfAvailable("file_search", "uploaded/vector documents -> file_search");
pushIfAvailable("read_file", "known local file path -> read_file");
pushIfAvailable("list_directory", "project structure or directory listing -> list_directory");
pushIfAvailable("search_files", "local file/content search or unknown file path -> search_files");
pushIfAvailable("read_user_info", "inspect remembered user info -> read_user_info");
pushIfAvailable("read_system_info", "inspect remembered assistant info -> read_system_info");
pushIfAvailable("add_user_info", "remember a new user fact -> add_user_info");
pushIfAvailable("add_system_info", "remember a new assistant instruction -> add_system_info");
pushIfAvailable("remove_user_info", "forget a user fact -> remove_user_info");
pushIfAvailable("remove_system_info", "forget an assistant instruction -> remove_system_info");
pushIfAvailable("replace_user_info", "overwrite all user memory -> replace_user_info");
pushIfAvailable("replace_system_info", "overwrite all assistant memory -> replace_system_info");
pushIfAvailable("delete_user_info", "delete all user memory -> delete_user_info");
pushIfAvailable("delete_system_info", "delete all assistant memory -> delete_system_info");
pushIfAvailable("edit_file_patch", "targeted existing file edit -> edit_file_patch");
pushIfAvailable("update_file", "full existing file replacement -> update_file");
pushIfAvailable("create_file", "small new file -> create_file");
pushIfAvailable("begin_file_write", "large file writing -> begin_file_write + write_file_chunk + finish_file_write");
pushIfAvailable("delete_path", "delete/remove only when the user clearly asks -> delete_path");
pushIfAvailable("shell_execute", "terminal commands, builds, tests, git, docker -> shell_execute");
pushIfAvailable("python_interpreter", "explicit Python execution -> python_interpreter");
pushIfAvailable("code_interpreter", "sandbox computation or data analysis -> code_interpreter");
return lines;
}
function buildRulesSection(availableToolNames: readonly string[]): string[] {
const names = new Set(availableToolNames);
const rules: string[] = [
"You are a tool router, not an answering model.",
"Your only job is to select the minimal set of tools needed for the user's latest request.",
"Return ONLY valid JSON: {\"toolNames\":[\"tool1\",\"tool2\"]}",
"No explanations.",
"No markdown.",
"No arguments.",
"Use only tool names from Available tools.",
"If no tool is needed, return {\"toolNames\":[\"no_tool\"]}.",
"Pick the smallest correct tool set.",
"Prefer specialized tools over generic tools.",
"Use multiple tools only when the request likely needs a combination of capabilities.",
"Be extra careful with destructive tools.",
];
if (names.has("web_search")) {
rules.push("Do not use web_search just because you are unsure.");
}
if (names.has("delete_path")) {
rules.push("delete_path only when the user clearly asks to delete or remove something.");
}
if (names.has("update_file")) {
rules.push("update_file only for full file replacement.");
}
if (names.has("edit_file_patch")) {
rules.push("edit_file_patch for targeted file edits.");
}
return rules;
}
export function buildToolRankerSystemPrompt(params: {
availableTools: ToolRankerToolInfo[];
includeExamples?: boolean;
maxExamplesPerTool?: number;
compact?: boolean;
}): string {
const includeExamples = params.includeExamples ?? false;
const maxExamplesPerTool = Math.max(0, params.maxExamplesPerTool ?? 1);
const compact = params.compact ?? true;
const availableTools = params.availableTools;
const availableToolNames = availableTools.map(tool => tool.name);
const sections: string[] = [
...buildRulesSection(availableToolNames),
"",
"Available tools:",
...availableTools.map(tool => renderToolLine(tool, compact)),
];
const priorityLines = buildPriorityLines(availableTools);
if (priorityLines.length) {
sections.push("", "Priority:", ...priorityLines);
}
if (includeExamples) {
const exampleLines = availableTools.flatMap(tool => renderExamples(tool, maxExamplesPerTool));
if (exampleLines.length) {
sections.push("", "Examples:", ...exampleLines);
}
}
sections.push("", "Return ONLY JSON.");
return sections.join("\n");
}
export function sanitizeToolRankerResult(params: {
raw: string;
availableToolNames: readonly string[];
}): string[] {
const raw = params.raw.trim();
if (!raw) {
return ["no_tool"];
}
const candidate = extractJsonCandidate(raw);
let parsed: BoundaryValue;
try {
parsed = JSON.parse(candidate) as BoundaryValue;
} catch {
return ["no_tool"];
}
const availableToolNames = new Set(params.availableToolNames.filter(Boolean));
const selected: string[] = [];
for (const name of normalizeToolNames(parseSelectionValue(parsed))) {
if (name === "no_tool") {
selected.push(name);
continue;
}
if (availableToolNames.has(name)) {
selected.push(name);
}
}
const deduped = normalizeToolNames(selected);
const withoutNoTool = deduped.filter(name => name !== "no_tool");
return withoutNoTool.length > 0 ? withoutNoTool : ["no_tool"];
}
-116
View File
@@ -1,116 +0,0 @@
import type {BoundaryValue} from "../common/boundary-types.js";
import type {AiRuntimeTarget} from "./ai-runtime-target.js";
import {AiProvider} from "../model/ai-provider.js";
import {RuntimeConfigSnapshot, toolSchemaNames} from "./unified-ai-runner.shared.js";
import {
buildToolRankerSystemPrompt,
getToolRankerAvailableToolInfos,
type ToolRankerToolInfo,
} from "./tool-ranker-metadata.js";
export type ToolRankerMessage = {
role?: string;
content?: BoundaryValue;
};
export type ToolRankerSelection = {
toolNames: string[];
usedRanker: boolean;
};
export type ToolRankerContext = {
provider: AiProvider;
round: number;
userQuery: string;
availableTools: readonly BoundaryValue[];
targetModel: string;
rankerPrompt?: string | null;
promptAdditions?: string | null;
};
export type ToolRankerPromptPlan = {
availableToolNames: string[];
availableToolInfos: ToolRankerToolInfo[];
prompt: string;
};
export function latestUserTextFromMessages(messages: readonly ToolRankerMessage[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message?.role !== "user") continue;
if (typeof message.content === "string") return message.content;
if (Array.isArray(message.content)) {
return message.content
.map(part => {
if (typeof part === "object" && part !== null && "text" in part && typeof part.text === "string") {
return part.text;
}
return "";
})
.filter(Boolean)
.join("\n");
}
}
return "";
}
export function buildToolRankerPrompt(context: ToolRankerContext): ToolRankerPromptPlan {
const availableToolInfos = getToolRankerAvailableToolInfos(context.availableTools);
const availableToolNames = availableToolInfos.map(tool => tool.name);
const prompt = buildToolRankerSystemPrompt({
availableTools: availableToolInfos,
includeExamples: true,
maxExamplesPerTool: 1,
compact: true,
});
return {
availableToolNames,
availableToolInfos,
prompt: [
context.rankerPrompt?.trim() || null,
context.promptAdditions?.trim() || null,
prompt,
].filter((line): line is string => Boolean(line?.trim?.() ?? line)).join("\n\n"),
};
}
export function filterRankedTools<T extends BoundaryValue>(availableTools: readonly T[], toolNames: readonly string[]): T[] {
const selected = new Set(toolNames);
return availableTools.filter(tool => toolSchemaNames(tool).some(name => selected.has(name)));
}
export function buildRankerContext(config: RuntimeConfigSnapshot, provider: AiProvider, target: AiRuntimeTarget, round: number, userQuery: string, availableTools: readonly BoundaryValue[]): ToolRankerContext {
return {
provider,
round,
userQuery,
availableTools,
targetModel: target.model,
rankerPrompt: config.rankerToolPrompt,
promptAdditions: target.systemPromptAdditions ?? null,
};
}
export function buildRankerTarget(config: RuntimeConfigSnapshot, provider: AiProvider): AiRuntimeTarget | undefined {
const target = provider === AiProvider.OLLAMA
? config.ollamaToolRankerTarget
: provider === AiProvider.MISTRAL
? config.mistralToolRankerTarget
: provider === AiProvider.OPENAI
? config.openAiToolRankerTarget
: undefined;
if (!target?.model) return undefined;
return {
provider: target.provider,
purpose: target.purpose,
model: target.model,
baseUrl: target.baseUrl,
apiKey: target.apiKey,
systemPromptAdditions: target.systemPromptAdditions ?? null,
};
}
-28
View File
@@ -1,28 +0,0 @@
import type {StoredAttachment} from "../model/stored-attachment";
import type {ToolCallData} from "./unified-ai-runner.shared";
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
export async function persistToolResultArtifactAttachment(params: {
toolCall: ToolCallData;
resultText: string;
chatId: number;
messageId: number;
}): Promise<StoredAttachment> {
return await persistInternalJsonArtifactAttachment({
artifactKind: "tool_result",
fileNamePrefix: `tool-${params.toolCall.name}`,
chatId: params.chatId,
messageId: params.messageId,
payload: {
toolName: params.toolCall.name,
callId: params.toolCall.id,
argumentsText: params.toolCall.argumentsText,
resultText: params.resultText,
},
metadata: {
toolName: params.toolCall.name,
callId: params.toolCall.id,
resultChars: params.resultText.length,
},
});
}
-33
View File
@@ -1,33 +0,0 @@
import type {BoundaryValue} from "../common/boundary-types.js";
function isRecord(value: BoundaryValue): value is Record<string, BoundaryValue> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function asOptionalString(value: BoundaryValue): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
export function toolSchemaName(tool: BoundaryValue): string | undefined {
if (!isRecord(tool)) return undefined;
const fn = isRecord(tool.function) ? tool.function : undefined;
const directName = fn?.name ?? tool.name ?? (typeof tool.type === "string" && tool.type !== "function" ? tool.type : undefined);
return asOptionalString(directName);
}
export function toolSchemaNames(tool: BoundaryValue): string[] {
if (!isRecord(tool)) return [];
if (Array.isArray(tool.functionDeclarations)) {
return tool.functionDeclarations
.map(declaration => isRecord(declaration) ? asOptionalString(declaration.name) : undefined)
.filter((name): name is string => !!name);
}
const name = toolSchemaName(tool);
return name ? [name] : [];
}
export function allToolSchemaNames(tools: readonly BoundaryValue[]): string[] {
return [...new Set(tools.flatMap(toolSchemaNames))];
}
+11 -20
View File
@@ -1,23 +1,12 @@
export type AiJsonPrimitive = string | number | boolean | null;
export interface AiJsonObject {
readonly [key: string]: AiJsonValue; export type AiToolParameters = {
} type: "object";
export type AiJsonValue = AiJsonPrimitive | undefined | readonly AiJsonValue[] | AiJsonObject; properties?: Record<string, unknown>;
export interface AiToolParameters { required?: string[];
type: "object" | "string" | "number" | "integer" | "boolean" | "array"; [key: string]: unknown;
properties?: Record<string, AiToolParameters>; };
required?: readonly string[];
items?: AiToolParameters;
enum?: readonly string[];
description?: string;
minItems?: number;
maxItems?: number;
minimum?: number;
maximum?: number;
default?: AiJsonValue;
additionalProperties?: boolean | AiToolParameters;
}
export type AiTool = { export type AiTool = {
type: "function"; type: "function";
@@ -32,7 +21,9 @@ export type AiTool = {
export type AiToolCall = { export type AiToolCall = {
function: { function: {
name: string; name: string;
arguments: AiJsonObject; arguments: {
[key: string]: unknown;
};
}; };
}; };
@@ -1,11 +1,11 @@
import axios from "axios"; import axios from "axios";
import {toolsLogger} from "./tool-logger.js"; import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("brave-search"); const logger = toolsLogger.child("brave-search");
import {Environment} from "../../common/environment.js"; import {Environment} from "../../common/environment";
import {logError} from "../../util/utils.js"; import {logError} from "../../util/utils";
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types.js"; import {AiTool} from "../tool-types";
import {asBoolean, asNonEmptyString} from "./utils.js"; import {asBoolean, asNonEmptyString} from "./utils";
type BraveSearchProfile = { type BraveSearchProfile = {
name?: string; name?: string;
@@ -83,19 +83,17 @@ type BraveSearchApiResponse = {
results?: BraveSearchResult[]; results?: BraveSearchResult[];
}; };
faq?: AiJsonValue; faq?: unknown;
infobox?: AiJsonValue; infobox?: unknown;
locations?: AiJsonValue; locations?: unknown;
mixed?: AiJsonValue; mixed?: unknown;
summarizer?: AiJsonValue; summarizer?: unknown;
}; };
export const WEB_SEARCH_TOOL_NAME = "web_search"; export const braveSearchTool = {
export const webSearchTool = {
type: "function", type: "function",
function: { function: {
name: WEB_SEARCH_TOOL_NAME, name: "web_search",
description: description:
"Search the web using Brave Search API. Use this for current information, facts, documentation, news, products, recent events, source lookup, and general web search. Returns ranked web/news/video results with titles, URLs and snippets.", "Search the web using Brave Search API. Use this for current information, facts, documentation, news, products, recent events, source lookup, and general web search. Returns ranked web/news/video results with titles, URLs and snippets.",
parameters: { parameters: {
@@ -163,7 +161,7 @@ export const webSearchTool = {
}, },
} satisfies AiTool; } satisfies AiTool;
export const webSearchToolPrompt = [ export const braveSearchToolPrompt = [
"Brave Search tool rules:", "Brave Search tool rules:",
"- You have access to `web_search`.", "- You have access to `web_search`.",
"- Use `web_search` when the user asks for current information, recent events, fresh prices, documentation lookup, source lookup, product info, news, public facts, or anything that may have changed.", "- Use `web_search` when the user asks for current information, recent events, fresh prices, documentation lookup, source lookup, product info, news, public facts, or anything that may have changed.",
@@ -197,7 +195,7 @@ export const webSearchToolPrompt = [
].join("\n"); ].join("\n");
function asIntegerInRange( function asIntegerInRange(
value: AiJsonValue | undefined, value: unknown,
fallback: number, fallback: number,
min: number, min: number,
max: number, max: number,
@@ -216,7 +214,7 @@ function asIntegerInRange(
} }
function asEnum<T extends string>( function asEnum<T extends string>(
value: AiJsonValue | undefined, value: unknown,
allowed: readonly T[], allowed: readonly T[],
fallback: T, fallback: T,
): T { ): T {
@@ -229,7 +227,7 @@ function asEnum<T extends string>(
: fallback; : fallback;
} }
function cleanSearchText(value: AiJsonValue | undefined): string | null { function cleanSearchText(value: unknown): string | null {
if (typeof value !== "string") return null; if (typeof value !== "string") return null;
return value return value
@@ -243,7 +241,7 @@ function cleanSearchText(value: AiJsonValue | undefined): string | null {
.trim() || null; .trim() || null;
} }
function normalizeBraveResultFilter(value: AiJsonValue | undefined): string { function normalizeBraveResultFilter(value: unknown): string {
const allowed = new Set([ const allowed = new Set([
"discussions", "discussions",
"faq", "faq",
@@ -268,7 +266,7 @@ function normalizeBraveResultFilter(value: AiJsonValue | undefined): string {
return parts.length ? [...new Set(parts)].join(",") : "web"; return parts.length ? [...new Set(parts)].join(",") : "web";
} }
export async function webSearch(args?: AiJsonObject) { export async function webSearch(args?: Record<string, unknown>) {
const startedAt = Date.now(); const startedAt = Date.now();
logger.info("start", {args}); logger.info("start", {args});
@@ -362,16 +360,17 @@ export async function webSearch(args?: AiJsonObject) {
note: "Use returned URLs as sources. Do not invent facts that are not present in the snippets/results.", note: "Use returned URLs as sources. Do not invent facts that are not present in the snippets/results.",
}; };
} catch (error) { } catch (e: unknown) {
logError(error instanceof Error ? error : String(error)); logError(e);
const status = axios.isAxiosError(error) ? error.response?.status : undefined; const axiosLike = e as {response?: {status?: unknown; data?: unknown}};
const data = axios.isAxiosError(error) ? error.response?.data : undefined; const status = axiosLike.response?.status;
const data = axiosLike.response?.data;
return { return {
ok: false, ok: false,
status: typeof status === "number" ? status : null, status: typeof status === "number" ? status : null,
error: error instanceof Error ? error.message : String(error), error: e instanceof Error ? e.message : String(e),
response: data ?? null, response: data ?? null,
}; };
} finally { } finally {
+6 -7
View File
@@ -1,11 +1,10 @@
import {AiTool} from "../tool-types.js"; import {AiTool} from "../tool-types";
import path from "node:path"; import path from "node:path";
import {readFile, writeFile} from "node:fs/promises"; import {readFile, writeFile} from "node:fs/promises";
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index.js"; import {NOTES_HEADER, notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils.js"; import {asNonEmptyString} from "./utils";
import fs from "node:fs"; import fs from "node:fs";
import {toolsLogger} from "./tool-logger.js"; import {toolsLogger} from "./tool-logger";
import {AiJsonObject} from "../tool-types.js";
const logger = toolsLogger.child("create-note"); const logger = toolsLogger.child("create-note");
@@ -40,7 +39,7 @@ export const createNoteTool = {
} satisfies AiTool; } satisfies AiTool;
export async function createNote( export async function createNote(
args?: AiJsonObject args?: Record<string, unknown>
): Promise<CreateNoteResult> { ): Promise<CreateNoteResult> {
const startedAt = Date.now(); const startedAt = Date.now();
logger.debug("start", {args}); logger.debug("start", {args});
@@ -84,7 +83,7 @@ export async function createNote(
logger.debug("done", {fileName, filePath: newFilePath, duration: logger.duration(startedAt)}); logger.debug("done", {fileName, filePath: newFilePath, duration: logger.duration(startedAt)});
return {success: true, filePath: newFilePath}; return {success: true, filePath: newFilePath};
} catch (error) { } catch (error) {
logger.error("failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); logger.error("failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to process files: ${errorMessage}`}; return {success: false, error: `Failed to process files: ${errorMessage}`};
} }
+2 -3
View File
@@ -1,6 +1,5 @@
import {AiTool} from "../tool-types"; import {AiTool} from "../tool-types";
import {asNonEmptyString} from "./utils.js"; import {asNonEmptyString} from "./utils";
import {AiJsonObject} from "../tool-types";
export const getCurrentDateTimeTool = { export const getCurrentDateTimeTool = {
type: "function", type: "function",
@@ -45,7 +44,7 @@ function getSystemTimeZone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone; return Intl.DateTimeFormat().resolvedOptions().timeZone;
} }
export function getCurrentDateTime(args?: AiJsonObject) { export function getCurrentDateTime(args?: Record<string, unknown>) {
const now = new Date(); const now = new Date();
const systemTimeZone = getSystemTimeZone(); const systemTimeZone = getSystemTimeZone();
+852
View File
@@ -0,0 +1,852 @@
import fs from "node:fs";
import path from "node:path";
import {Environment} from "../../common/environment";
import {AiTool} from "../tool-types";
import {MAX_COPY_ENTRIES, MAX_COPY_TOTAL_BYTES, MAX_DIRECTORY_ENTRIES, MAX_FILE_READ_BYTES, MAX_FILE_WRITE_BYTES} from "./limits";
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils";
export const readFileTool = {
type: "function",
function: {
name: "read_file",
description:
"Read a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative file path inside the root directory, for example notes/task.txt.",
},
maxBytes: {
type: "number",
description: `Optional max bytes to read. Maximum allowed value is ${MAX_FILE_READ_BYTES}.`,
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const listDirectoryTool = {
type: "function",
function: {
name: "list_directory",
description:
"List files and directories inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative directory path inside the root directory. Use . for root.",
},
},
required: [],
},
},
} satisfies AiTool;
export const createFileTool = {
type: "function",
function: {
name: "create_file",
description:
"Create a UTF-8 text file inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative file path inside the root directory.",
},
content: {
type: "string",
description: "File content.",
},
overwrite: {
type: "boolean",
description: "Whether to overwrite the file if it already exists. Default is false.",
},
createParents: {
type: "boolean",
description: "Whether to create parent directories automatically. Default is true.",
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const createDirectoryTool = {
type: "function",
function: {
name: "create_directory",
description:
"Create a directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative directory path inside the root directory.",
},
recursive: {
type: "boolean",
description: "Whether to create parent directories automatically. Default is true.",
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const copyPathTool = {
type: "function",
function: {
name: "copy_path",
description:
"Copy a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Directory copy requires recursive=true. Symlinks are forbidden.",
parameters: {
type: "object",
properties: {
sourcePath: {
type: "string",
description: "Relative source file or directory path inside the root directory.",
},
targetPath: {
type: "string",
description: "Relative target file or directory path inside the root directory.",
},
recursive: {
type: "boolean",
description: "Required for copying directories. Default is false.",
},
overwrite: {
type: "boolean",
description: "Whether to overwrite existing files. Directory merge is allowed, but existing directories are not deleted. Default is false.",
},
createParents: {
type: "boolean",
description: "Whether to create target parent directories automatically. Default is true.",
},
},
required: ["sourcePath", "targetPath"],
},
},
} satisfies AiTool;
export const updateFileTool = {
type: "function",
function: {
name: "update_file",
description:
"Update a UTF-8 text file inside the hardcoded root directory. Supports replace, append and prepend. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative file path inside the root directory.",
},
content: {
type: "string",
description: "Content to write.",
},
mode: {
type: "string",
enum: ["replace", "append", "prepend"],
description: "Update mode. Default is replace.",
},
createIfMissing: {
type: "boolean",
description: "Whether to create the file if it does not exist. Default is false.",
},
},
required: ["path", "content"],
},
},
} satisfies AiTool;
export const renamePathTool = {
type: "function",
function: {
name: "rename_path",
description:
"Rename or move a file/directory inside the hardcoded root directory. This is the main directory modification tool. Use only relative paths. Going up with ../ and absolute paths are forbidden.",
parameters: {
type: "object",
properties: {
sourcePath: {
type: "string",
description: "Relative source path inside the root directory.",
},
targetPath: {
type: "string",
description: "Relative target path inside the root directory.",
},
overwrite: {
type: "boolean",
description: "Whether to overwrite an existing target file. Directory overwrite is not supported. Default is false.",
},
createParents: {
type: "boolean",
description: "Whether to create target parent directories automatically. Default is false.",
},
},
required: ["sourcePath", "targetPath"],
},
},
} satisfies AiTool;
export const deletePathTool = {
type: "function",
function: {
name: "delete_path",
description:
"Delete a file or directory inside the hardcoded root directory. Use only relative paths. Going up with ../ and absolute paths are forbidden. Recursive deletion requires recursive=true.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Relative file or directory path inside the root directory.",
},
recursive: {
type: "boolean",
description: "Whether to delete non-empty directories recursively. Default is false.",
},
},
required: ["path"],
},
},
} satisfies AiTool;
export const fileToolsToolPrompt = [
"Filesystem tool rules:",
"- You have access to filesystem tools working only inside the hardcoded root directory.",
"- All filesystem paths must be relative to the root directory.",
"- You may go into child directories.",
"- You must never go up to parent directories.",
"- Do not use ../ paths.",
"- Do not use absolute paths.",
"- Do not try to access symlinks.",
"- Use read_file for reading files.",
"- Use list_directory for reading directories.",
"- Use create_file for creating files.",
"- Use create_directory for creating directories.",
"- Use update_file for replacing, appending or prepending file content.",
"- Use rename_path for renaming or moving files/directories inside the root.",
"- Use delete_path for deleting files/directories inside the root.",
""
].join("\n");
const requireFileToolsRootDir = () => <string>Environment.FILE_TOOLS_ROOT_DIR;
async function ensureFileToolsRootExists(): Promise<void> {
await fs.promises.mkdir(requireFileToolsRootDir(), {recursive: true});
const stat = await fs.promises.stat(requireFileToolsRootDir());
if (!stat.isDirectory()) {
throw new Error(`File tools root is not a directory: ${requireFileToolsRootDir()}`);
}
}
function resolveSafeToolPath(inputPath: unknown, fallback = "."): {
absolutePath: string;
relativePath: string;
} {
const rawPath = asNonEmptyString(inputPath) ?? fallback;
if (rawPath.includes("\0")) {
throw new Error("Path must not contain null bytes.");
}
if (
path.isAbsolute(rawPath) ||
path.win32.isAbsolute(rawPath) ||
path.posix.isAbsolute(rawPath)
) {
throw new Error("Absolute paths are not allowed. Use only relative paths inside the root directory.");
}
const normalizedInputPath = rawPath.replace(/[\\/]+/g, path.sep);
const absolutePath = path.resolve(requireFileToolsRootDir(), normalizedInputPath);
const relativePath = path.relative(requireFileToolsRootDir(), absolutePath);
if (
relativePath.startsWith("..") ||
path.isAbsolute(relativePath)
) {
throw new Error("Path escapes the root directory. Going up is not allowed.");
}
return {
absolutePath,
relativePath: relativePath || ".",
};
}
function assertTargetIsNotInsideSource(sourceAbsolutePath: string, targetAbsolutePath: string): void {
const relative = path.relative(sourceAbsolutePath, targetAbsolutePath);
if (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
) {
throw new Error("Cannot copy a directory into itself.");
}
}
async function assertNoSymlinkInPath(
absolutePath: string,
options?: {
allowMissingTail?: boolean;
}
): Promise<void> {
await ensureFileToolsRootExists();
const relativePath = path.relative(requireFileToolsRootDir(), absolutePath);
if (!relativePath || relativePath === ".") {
return;
}
const parts = relativePath.split(path.sep).filter(Boolean);
let currentPath = requireFileToolsRootDir();
for (const part of parts) {
currentPath = path.join(currentPath, part);
try {
const stat = await fs.promises.lstat(currentPath);
if (stat.isSymbolicLink()) {
throw new Error("Symlinks are not allowed in file tool paths.");
}
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === "ENOENT" && options?.allowMissingTail) {
return;
}
throw e;
}
}
}
async function pathExists(absolutePath: string): Promise<boolean> {
try {
await fs.promises.lstat(absolutePath);
return true;
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException).code === "ENOENT") return false;
throw e;
}
}
function assertNotRoot(relativePath: string): void {
if (relativePath === ".") {
throw new Error("Operation on the root directory itself is not allowed.");
}
}
function getEntryType(stat: fs.Stats): "file" | "directory" | "symlink" | "other" {
if (stat.isSymbolicLink()) return "symlink";
if (stat.isFile()) return "file";
if (stat.isDirectory()) return "directory";
return "other";
}
export async function readFile(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
await assertNoSymlinkInPath(absolutePath);
const stat = await fs.promises.lstat(absolutePath);
if (!stat.isFile()) {
throw new Error(`Path is not a file: ${relativePath}`);
}
const maxBytes = asPositiveInt(args?.maxBytes, MAX_FILE_READ_BYTES, MAX_FILE_READ_BYTES);
if (stat.size > maxBytes) {
throw new Error(`File is too large: ${stat.size} bytes. Max allowed: ${maxBytes} bytes.`);
}
const buffer = await fs.promises.readFile(absolutePath);
if (buffer.includes(0)) {
throw new Error("Binary files are not supported.");
}
return {
ok: true,
path: relativePath,
sizeBytes: stat.size,
content: buffer.toString("utf8"),
};
}
export async function listDirectory(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path, ".");
await assertNoSymlinkInPath(absolutePath);
const stat = await fs.promises.lstat(absolutePath);
if (!stat.isDirectory()) {
throw new Error(`Path is not a directory: ${relativePath}`);
}
const dirEntries = await fs.promises.readdir(absolutePath, {
withFileTypes: true,
});
const limitedEntries = dirEntries.slice(0, MAX_DIRECTORY_ENTRIES);
const entries = await Promise.all(limitedEntries.map(async entry => {
const entryAbsolutePath = path.join(absolutePath, entry.name);
const entryRelativePath = relativePath === "."
? entry.name
: path.join(relativePath, entry.name);
const entryStat = await fs.promises.lstat(entryAbsolutePath);
return {
name: entry.name,
path: entryRelativePath,
type: getEntryType(entryStat),
sizeBytes: entryStat.isFile() ? entryStat.size : null,
modifiedAt: entryStat.mtime.toISOString(),
};
}));
return {
ok: true,
path: relativePath,
entries,
totalEntries: dirEntries.length,
returnedEntries: entries.length,
truncated: dirEntries.length > entries.length,
};
}
export async function createFile(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
assertNotRoot(relativePath);
const content = asString(args?.content, "");
const overwrite = asBoolean(args?.overwrite, false);
const createParents = asBoolean(args?.createParents, true);
const contentSizeBytes = Buffer.byteLength(content, "utf8");
if (contentSizeBytes > MAX_FILE_WRITE_BYTES) {
throw new Error(`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
}
const parentPath = path.dirname(absolutePath);
if (createParents) {
await assertNoSymlinkInPath(parentPath, {allowMissingTail: true});
await fs.promises.mkdir(parentPath, {recursive: true});
} else {
await assertNoSymlinkInPath(parentPath);
}
if (await pathExists(absolutePath)) {
const stat = await fs.promises.lstat(absolutePath);
if (stat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (stat.isDirectory()) {
throw new Error(`Path is a directory, not a file: ${relativePath}`);
}
if (!overwrite) {
throw new Error(`File already exists: ${relativePath}`);
}
}
await fs.promises.writeFile(absolutePath, content, {
encoding: "utf8",
flag: overwrite ? "w" : "wx",
});
return {
ok: true,
path: relativePath,
sizeBytes: contentSizeBytes,
overwritten: overwrite,
};
}
type CopyPathStats = {
entries: number;
totalBytes: number;
};
async function copyPathRecursive(params: {
sourceAbsolutePath: string;
targetAbsolutePath: string;
overwrite: boolean;
stats: CopyPathStats;
}): Promise<void> {
const {
sourceAbsolutePath,
targetAbsolutePath,
overwrite,
stats,
} = params;
if (stats.entries >= MAX_COPY_ENTRIES) {
throw new Error(`Too many entries to copy. Max allowed: ${MAX_COPY_ENTRIES}.`);
}
stats.entries++;
const sourceStat = await fs.promises.lstat(sourceAbsolutePath);
if (sourceStat.isSymbolicLink()) {
throw new Error("Symlinks are not allowed in copied paths.");
}
if (sourceStat.isFile()) {
stats.totalBytes += sourceStat.size;
if (stats.totalBytes > MAX_COPY_TOTAL_BYTES) {
throw new Error(`Copied data is too large. Max allowed: ${MAX_COPY_TOTAL_BYTES} bytes.`);
}
if (await pathExists(targetAbsolutePath)) {
const targetStat = await fs.promises.lstat(targetAbsolutePath);
if (targetStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (targetStat.isDirectory()) {
throw new Error("Cannot overwrite a directory with a file.");
}
if (!overwrite) {
throw new Error(`Target file already exists: ${path.relative(requireFileToolsRootDir(), targetAbsolutePath)}`);
}
}
await fs.promises.copyFile(
sourceAbsolutePath,
targetAbsolutePath,
overwrite ? 0 : fs.constants.COPYFILE_EXCL,
);
return;
}
if (sourceStat.isDirectory()) {
if (await pathExists(targetAbsolutePath)) {
const targetStat = await fs.promises.lstat(targetAbsolutePath);
if (targetStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (!targetStat.isDirectory()) {
throw new Error("Cannot overwrite a file with a directory.");
}
} else {
await fs.promises.mkdir(targetAbsolutePath);
}
const entries = await fs.promises.readdir(sourceAbsolutePath);
for (const entry of entries) {
const childSourcePath = path.join(sourceAbsolutePath, entry);
const childTargetPath = path.join(targetAbsolutePath, entry);
await copyPathRecursive({
sourceAbsolutePath: childSourcePath,
targetAbsolutePath: childTargetPath,
overwrite,
stats,
});
}
return;
}
throw new Error("Only files and directories can be copied.");
}
export async function copyPath(args?: Record<string, unknown>) {
const source = resolveSafeToolPath(args?.sourcePath);
const target = resolveSafeToolPath(args?.targetPath);
assertNotRoot(source.relativePath);
assertNotRoot(target.relativePath);
await assertNoSymlinkInPath(source.absolutePath);
const sourceStat = await fs.promises.lstat(source.absolutePath);
if (sourceStat.isSymbolicLink()) {
throw new Error("Symlink sources are not allowed.");
}
const recursive = asBoolean(args?.recursive, false);
const overwrite = asBoolean(args?.overwrite, false);
const createParents = asBoolean(args?.createParents, true);
if (sourceStat.isDirectory() && !recursive) {
throw new Error("Source is a directory. Set recursive=true to copy directories.");
}
if (sourceStat.isDirectory()) {
assertTargetIsNotInsideSource(source.absolutePath, target.absolutePath);
}
const targetParentPath = path.dirname(target.absolutePath);
if (createParents) {
await assertNoSymlinkInPath(targetParentPath, {
allowMissingTail: true,
});
await fs.promises.mkdir(targetParentPath, {
recursive: true,
});
await assertNoSymlinkInPath(targetParentPath);
} else {
await assertNoSymlinkInPath(targetParentPath);
}
const stats: CopyPathStats = {
entries: 0,
totalBytes: 0,
};
await copyPathRecursive({
sourceAbsolutePath: source.absolutePath,
targetAbsolutePath: target.absolutePath,
overwrite,
stats,
});
return {
ok: true,
from: source.relativePath,
to: target.relativePath,
recursive,
overwrite,
entriesCopied: stats.entries,
bytesCopied: stats.totalBytes,
};
}
export async function createDirectory(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
const recursive = asBoolean(args?.recursive, true);
await assertNoSymlinkInPath(absolutePath, {
allowMissingTail: true,
});
await fs.promises.mkdir(absolutePath, {
recursive,
});
return {
ok: true,
path: relativePath,
recursive,
};
}
export async function updateFile(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
assertNotRoot(relativePath);
const content = asString(args?.content, "");
const mode = (asNonEmptyString(args?.mode) ?? "replace").toLowerCase();
const createIfMissing = asBoolean(args?.createIfMissing, false);
if (!["replace", "append", "prepend"].includes(mode)) {
throw new Error(`Unsupported update mode: ${mode}`);
}
const contentSizeBytes = Buffer.byteLength(content, "utf8");
if (contentSizeBytes > MAX_FILE_WRITE_BYTES) {
throw new Error(`Content is too large: ${contentSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
}
const parentPath = path.dirname(absolutePath);
await assertNoSymlinkInPath(parentPath);
const exists = await pathExists(absolutePath);
if (!exists && !createIfMissing) {
throw new Error(`File does not exist: ${relativePath}`);
}
if (exists) {
await assertNoSymlinkInPath(absolutePath);
const stat = await fs.promises.lstat(absolutePath);
if (!stat.isFile()) {
throw new Error(`Path is not a file: ${relativePath}`);
}
}
if (mode === "replace") {
await fs.promises.writeFile(absolutePath, content, {
encoding: "utf8",
flag: "w",
});
} else if (mode === "append") {
await fs.promises.appendFile(absolutePath, content, {
encoding: "utf8",
});
} else {
const oldContent = exists
? await fs.promises.readFile(absolutePath, "utf8")
: "";
const resultSizeBytes = Buffer.byteLength(content + oldContent, "utf8");
if (resultSizeBytes > MAX_FILE_WRITE_BYTES) {
throw new Error(`Result file content is too large: ${resultSizeBytes} bytes. Max allowed: ${MAX_FILE_WRITE_BYTES} bytes.`);
}
await fs.promises.writeFile(absolutePath, content + oldContent, {
encoding: "utf8",
flag: "w",
});
}
const newStat = await fs.promises.stat(absolutePath);
return {
ok: true,
path: relativePath,
mode,
sizeBytes: newStat.size,
created: !exists,
};
}
export async function renamePath(args?: Record<string, unknown>) {
const source = resolveSafeToolPath(args?.sourcePath);
const target = resolveSafeToolPath(args?.targetPath);
assertNotRoot(source.relativePath);
assertNotRoot(target.relativePath);
await assertNoSymlinkInPath(source.absolutePath);
const sourceStat = await fs.promises.lstat(source.absolutePath);
if (sourceStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
const relativeTargetInsideSource = path.relative(source.absolutePath, target.absolutePath);
if (
relativeTargetInsideSource === "" ||
(!relativeTargetInsideSource.startsWith("..") && !path.isAbsolute(relativeTargetInsideSource))
) {
throw new Error("Cannot move a directory into itself.");
}
const overwrite = asBoolean(args?.overwrite, false);
const createParents = asBoolean(args?.createParents, false);
const targetParentPath = path.dirname(target.absolutePath);
if (createParents) {
await assertNoSymlinkInPath(targetParentPath, {allowMissingTail: true});
await fs.promises.mkdir(targetParentPath, {recursive: true});
} else {
await assertNoSymlinkInPath(targetParentPath);
}
if (await pathExists(target.absolutePath)) {
const targetStat = await fs.promises.lstat(target.absolutePath);
if (targetStat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
if (!overwrite) {
throw new Error(`Target already exists: ${target.relativePath}`);
}
if (sourceStat.isDirectory() || targetStat.isDirectory()) {
throw new Error("Overwrite for directories is not supported.");
}
await fs.promises.rm(target.absolutePath, {
force: false,
});
}
await fs.promises.rename(source.absolutePath, target.absolutePath);
return {
ok: true,
from: source.relativePath,
to: target.relativePath,
overwrite,
};
}
export async function deletePath(args?: Record<string, unknown>) {
const {absolutePath, relativePath} = resolveSafeToolPath(args?.path);
assertNotRoot(relativePath);
await assertNoSymlinkInPath(absolutePath);
const stat = await fs.promises.lstat(absolutePath);
if (stat.isSymbolicLink()) {
throw new Error("Symlink targets are not allowed.");
}
const recursive = asBoolean(args?.recursive, false);
if (stat.isDirectory()) {
if (recursive) {
await fs.promises.rm(absolutePath, {
recursive: true,
force: false,
});
} else {
await fs.promises.rmdir(absolutePath);
}
} else {
await fs.promises.rm(absolutePath, {
force: false,
});
}
return {
ok: true,
path: relativePath,
recursive,
deleted: true,
};
}
File diff suppressed because it is too large Load Diff
-12
View File
@@ -3,15 +3,3 @@ export const MAX_FILE_WRITE_BYTES = 128 * 1024 * 1024;
export const MAX_DIRECTORY_ENTRIES = 200; export const MAX_DIRECTORY_ENTRIES = 200;
export const MAX_COPY_TOTAL_BYTES = 10 * 1024 * 1024; export const MAX_COPY_TOTAL_BYTES = 10 * 1024 * 1024;
export const MAX_COPY_ENTRIES = 500; export const MAX_COPY_ENTRIES = 500;
export const MAX_PATCH_OPERATIONS = 20;
export const MAX_PATCH_SEARCH_BYTES = 64 * 1024;
export const MAX_PATCH_REPLACE_BYTES = 256 * 1024;
export const MAX_PATCH_PREVIEW_CHARS = 6000;
export const MAX_FILE_SEARCH_ENTRIES = 5000;
export const MAX_FILE_SEARCH_RESULTS = 100;
export const MAX_FILE_SEARCH_CONTENT_BYTES = 512 * 1024;
export const MAX_FILE_SEARCH_SNIPPET_CHARS = 300;
export const MAX_FILE_WRITE_CHUNK_BYTES = 64 * 1024;
export const MAX_STREAM_WRITE_SESSIONS = 20;
export const MAX_STREAM_WRITE_IDLE_MS = 15 * 60 * 1000;
export const MAX_FILE_ATTACHMENT_BYTES = 20 * 1024 * 1024;
@@ -1,11 +1,9 @@
import {AiTool} from "../tool-types.js"; import {AiTool} from "../tool-types";
import path from "node:path"; import path from "node:path";
import {readdir, readFile, stat, unlink, writeFile} from "node:fs/promises"; import {readdir, readFile, unlink, writeFile} from "node:fs/promises";
import {notesDir, notesRootFile} from "../../index.js"; import {notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils.js"; import {asNonEmptyString} from "./utils";
import {toolsLogger} from "./tool-logger.js"; import {toolsLogger} from "./tool-logger";
import {z} from "zod";
import {AiJsonObject} from "../tool-types.js";
const logger = toolsLogger.child("notes"); const logger = toolsLogger.child("notes");
@@ -72,7 +70,7 @@ export async function listNotes(): Promise<ListNotesResult> {
const markdownFiles = entries const markdownFiles = entries
.filter((entry) => entry.isFile()) .filter((entry) => entry.isFile())
.map((entry) => entry.name) .map((entry) => entry.name)
.filter((fileName) => fileName.endsWith(".md") && !fileName.startsWith("index")); .filter((fileName) => fileName.endsWith(".md"));
const notes: NoteListItem[] = await Promise.all( const notes: NoteListItem[] = await Promise.all(
markdownFiles.map(async (fileName) => { markdownFiles.map(async (fileName) => {
@@ -100,14 +98,14 @@ export async function listNotes(): Promise<ListNotesResult> {
logger.debug("list.done", {notes: notes.length, duration: logger.duration(startedAt)}); logger.debug("list.done", {notes: notes.length, duration: logger.duration(startedAt)});
return {success: true, notes}; return {success: true, notes};
} catch (error) { } catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to list notes: ${errorMessage}`}; return {success: false, error: `Failed to list notes: ${errorMessage}`};
} }
} }
export async function getNoteContent( export async function getNoteContent(
args?: AiJsonObject, args?: Record<string, unknown>,
): Promise<GetNoteContentResult> { ): Promise<GetNoteContentResult> {
const startedAt = Date.now(); const startedAt = Date.now();
logger.debug("get_content.start", {args}); logger.debug("get_content.start", {args});
@@ -117,10 +115,6 @@ export async function getNoteContent(
return {success: false, error: "No file name provided"}; return {success: false, error: "No file name provided"};
} }
if (fileName.trim().includes("index")) {
return {success: false, error: "It is forbidden to access `index.md`"};
}
const noteFilePath = buildSafeNoteFilePath(fileName); const noteFilePath = buildSafeNoteFilePath(fileName);
if (!noteFilePath) { if (!noteFilePath) {
return {success: false, error: "Invalid or unsafe file name provided"}; return {success: false, error: "Invalid or unsafe file name provided"};
@@ -131,12 +125,7 @@ export async function getNoteContent(
const normalizedFileName = path.basename(noteFilePath); const normalizedFileName = path.basename(noteFilePath);
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath); const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
logger.debug("get_content.done", { logger.debug("get_content.done", {fileName: normalizedFileName, relativePath, chars: content.length, duration: logger.duration(startedAt)});
fileName: normalizedFileName,
relativePath,
chars: content.length,
duration: logger.duration(startedAt)
});
return { return {
success: true, success: true,
fileName: normalizedFileName, fileName: normalizedFileName,
@@ -146,7 +135,7 @@ export async function getNoteContent(
content, content,
}; };
} catch (error) { } catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to read note: ${errorMessage}`}; return {success: false, error: `Failed to read note: ${errorMessage}`};
} }
@@ -221,14 +210,14 @@ export const deleteNoteTool = {
type: "function", type: "function",
function: { function: {
name: "delete_note", name: "delete_note",
description: "Delete an existing Markdown note by its file name and remove its link from the notes root file if present. It is forbidden to delete/edit/rename `index.md` note.", description: "Delete an existing Markdown note by its file name and remove its link from the notes root file if present.",
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
fileName: { fileName: {
type: "string", type: "string",
description: description:
"The file name of the note to delete. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters. It is forbidden to delete/edit/rename `index.md` note.", "The file name of the note to delete. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
}, },
}, },
required: ["fileName"], required: ["fileName"],
@@ -237,7 +226,7 @@ export const deleteNoteTool = {
} satisfies AiTool; } satisfies AiTool;
export async function updateNoteContent( export async function updateNoteContent(
args?: AiJsonObject, args?: Record<string, unknown>,
): Promise<UpdateNoteContentResult> { ): Promise<UpdateNoteContentResult> {
const startedAt = Date.now(); const startedAt = Date.now();
logger.debug("update_content.start", {args}); logger.debug("update_content.start", {args});
@@ -247,10 +236,6 @@ export async function updateNoteContent(
return {success: false, error: "No file name provided"}; return {success: false, error: "No file name provided"};
} }
if (fileName.trim().includes("index")) {
return {success: false, error: "It is forbidden to edit `index.md`"};
}
const content = asNonEmptyString(args?.content) ?? ""; const content = asNonEmptyString(args?.content) ?? "";
if (!content.trim().length) { if (!content.trim().length) {
return {success: false, error: "No content provided"}; return {success: false, error: "No content provided"};
@@ -264,23 +249,18 @@ export async function updateNoteContent(
try { try {
await readFile(noteFilePath, "utf-8"); await readFile(noteFilePath, "utf-8");
await writeFile(noteFilePath, content, "utf-8"); await writeFile(noteFilePath, content, "utf-8");
logger.debug("update_content.done", { logger.debug("update_content.done", {fileName, filePath: noteFilePath, chars: content.length, duration: logger.duration(startedAt)});
fileName,
filePath: noteFilePath,
chars: content.length,
duration: logger.duration(startedAt)
});
return {success: true, filePath: noteFilePath}; return {success: true, filePath: noteFilePath};
} catch (error) { } catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to update note: ${errorMessage}`}; return {success: false, error: `Failed to update note: ${errorMessage}`};
} }
} }
export async function deleteNote( export async function deleteNote(
args?: AiJsonObject, args?: Record<string, unknown>,
): Promise<DeleteNoteResult> { ): Promise<DeleteNoteResult> {
const startedAt = Date.now(); const startedAt = Date.now();
logger.debug("delete.start", {args}); logger.debug("delete.start", {args});
@@ -290,10 +270,6 @@ export async function deleteNote(
return {success: false, error: "No file name provided"}; return {success: false, error: "No file name provided"};
} }
if (fileName.trim().includes("index")) {
return {success: false, error: "It is forbidden to delete `index.md`"};
}
const noteFilePath = buildSafeNoteFilePath(fileName); const noteFilePath = buildSafeNoteFilePath(fileName);
if (!noteFilePath) { if (!noteFilePath) {
return {success: false, error: "Invalid or unsafe file name provided"}; return {success: false, error: "Invalid or unsafe file name provided"};
@@ -306,7 +282,7 @@ export async function deleteNote(
return {success: true, filePath: noteFilePath}; return {success: true, filePath: noteFilePath};
} catch (error) { } catch (error) {
logger.error("list.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); logger.error("list.failed", {duration: logger.duration(startedAt), error});
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to delete note: ${errorMessage}`}; return {success: false, error: `Failed to delete note: ${errorMessage}`};
} }
@@ -340,110 +316,3 @@ async function removeNoteLinkFromRoot(noteFilePath: string): Promise<void> {
function escapeRegExp(value: string): string { function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
export type NoteFileAttachment = {
type: "local_file";
fileName: string;
// filePath: string;
relativePath: string;
mimeType: "text/markdown";
sizeBytes: number;
};
export type GetNoteFileResult =
| {
success: true;
attachment: NoteFileAttachment;
} | { success: false; error: string };
export const NoteFileAttachmentSchema = z.object({
type: z.literal("local_file"),
fileName: z.string(),
// filePath: z.string(),
relativePath: z.string(),
mimeType: z.literal("text/markdown"),
sizeBytes: z.number(),
});
export const GetNoteFileResultSchema = z.discriminatedUnion("success", [
z.object({
success: z.literal(true),
attachment: NoteFileAttachmentSchema,
}),
z.object({
success: z.literal(false),
error: z.string(),
}),
]);
export const sendNoteAsFileTool = {
type: "function",
function: {
name: "send_note_as_file",
description:
"Prepare a Markdown note file to be sent to the user as a .md attachment. Returns a local file descriptor that the host application should use to upload or send the file.",
parameters: {
type: "object",
properties: {
fileName: {
type: "string",
description:
"The file name of the note to send. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
},
},
required: ["fileName"],
},
},
} satisfies AiTool;
export async function sendNoteAsFile(
args?: AiJsonObject,
): Promise<GetNoteFileResult> {
logger.debug("start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
return {success: false, error: "No file name provided"};
}
const noteFilePath = buildSafeNoteFilePath(fileName);
if (!noteFilePath) {
return {success: false, error: "Invalid or unsafe file name provided"};
}
try {
// Проверяем, что файл существует и действительно читается.
await readFile(noteFilePath, "utf-8");
const fileStat = await stat(noteFilePath);
if (!fileStat.isFile()) {
return {success: false, error: "Note path is not a file"};
}
const normalizedFileName = path.basename(noteFilePath);
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
const result: GetNoteFileResult = {
success: true,
attachment: {
type: "local_file",
fileName: normalizedFileName,
// filePath: noteFilePath,
relativePath,
mimeType: "text/markdown",
sizeBytes: fileStat.size,
},
};
logger.debug("done", {
fileName: result.attachment.fileName,
relativePath: result.attachment.relativePath,
sizeBytes: result.attachment.sizeBytes
});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to prepare note file: ${errorMessage}`};
}
}
+11 -14
View File
@@ -1,16 +1,13 @@
import {AiTool} from "../tool-types.js"; import {AiTool} from "../tool-types";
import axios from "axios"; import axios from "axios";
import {toolsLogger} from "./tool-logger.js"; import {toolsLogger} from "./tool-logger";
import {AiJsonObject} from "../tool-types.js";
const logger = toolsLogger.child("market-rates"); const logger = toolsLogger.child("market-rates");
export const GET_FINANCIAL_MARKET_DATA_TOOL_NAME = "get_financial_market_data"; export const getMarketRatesTool = {
export const getFinancialMarketData = {
type: "function", type: "function",
function: { function: {
name: GET_FINANCIAL_MARKET_DATA_TOOL_NAME, name: "get_market_rates",
description: description:
"Retrieve the latest exchange rates for supported currency, crypto, and precious metal pairs, including 24-hour change data when available. Supported pairs: USD/RUB, USD/EUR, USD/KZT, USD/UAH, USD/BYN, USD/GBP, USD/CNY, TON/USD, BTC/USD, ETH/USD, SOL/USD, and XAU/USD. Use this tool when the user asks for current rates, currency conversion, crypto prices, gold price, or recent 24-hour movement. This tool takes no parameters.", "Retrieve the latest exchange rates for supported currency, crypto, and precious metal pairs, including 24-hour change data when available. Supported pairs: USD/RUB, USD/EUR, USD/KZT, USD/UAH, USD/BYN, USD/GBP, USD/CNY, TON/USD, BTC/USD, ETH/USD, SOL/USD, and XAU/USD. Use this tool when the user asks for current rates, currency conversion, crypto prices, gold price, or recent 24-hour movement. This tool takes no parameters.",
parameters: { parameters: {
@@ -21,11 +18,11 @@ export const getFinancialMarketData = {
}, },
} satisfies AiTool; } satisfies AiTool;
export const getFinancialMarketDataToolPrompt = [ export const marketRatesToolPrompt = [
"Currency rates tool rules:", "Currency rates tool rules:",
`- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` whenever the answer depends on current exchange rates, crypto prices, or gold price.`, "- Use `get_market_rates` whenever the answer depends on current exchange rates, crypto prices, or gold price.",
`- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` when the user asks whether a supported asset went up or down recently.`, "- Use `get_market_rates` when the user asks whether a supported asset went up or down recently.",
`- Use \`${GET_FINANCIAL_MARKET_DATA_TOOL_NAME}\` when the user asks for the 24-hour change, percentage change, or movement direction for a supported pair.`, "- Use `get_market_rates` when the user asks for the 24-hour change, percentage change, or movement direction for a supported pair.",
"- Never guess current rates, prices, or 24-hour changes. Call the tool first.", "- Never guess current rates, prices, or 24-hour changes. Call the tool first.",
"- Do not use this tool for unsupported pairs unless the user asks about one of the supported pairs listed below.", "- Do not use this tool for unsupported pairs unless the user asks about one of the supported pairs listed below.",
"- Do not use this tool for historical rates beyond the provided 24-hour comparison.", "- Do not use this tool for historical rates beyond the provided 24-hour comparison.",
@@ -64,15 +61,15 @@ export const getFinancialMarketDataToolPrompt = [
"- When the user asks for all rates, group fiat currencies separately from crypto and gold.", "- When the user asks for all rates, group fiat currencies separately from crypto and gold.",
].join("\n"); ].join("\n");
export async function getMarketRates(): Promise<AiJsonObject | undefined> { export async function getMarketRates(): Promise<unknown | undefined> {
const startedAt = Date.now(); const startedAt = Date.now();
try { try {
logger.info("start"); logger.info("start");
const response = await axios.get("https://apid.r00t.top/api/v2/currency/rates"); const response = await axios.get("https://apid.r00t.top/api/v2/currency/rates");
logger.debug("done", {duration: logger.duration(startedAt), status: response.status}); logger.debug("done", {duration: logger.duration(startedAt), status: response.status});
return response.data; return response.data;
} catch (error) { } catch (e: unknown) {
logger.error("failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); logger.error("failed", {duration: logger.duration(startedAt), error: e});
return undefined; return undefined;
} }
} }
+21 -30
View File
@@ -1,12 +1,11 @@
import {spawn} from "node:child_process"; import {spawn} from "node:child_process";
import {copyFile, lstat, mkdir, readdir, rm, writeFile} from "node:fs/promises"; import {copyFile, lstat, mkdir, readdir, writeFile} from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import {AiTool} from "../tool-types.js"; import {AiTool} from "../tool-types";
import {Environment} from "../../common/environment.js"; import {Environment} from "../../common/environment";
import {toolsLogger} from "./tool-logger.js";
import {randomUUID} from "node:crypto"; import {randomUUID} from "node:crypto";
import {AiJsonObject} from "../tool-types.js"; import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("python-interpreter"); const logger = toolsLogger.child("python-interpreter");
@@ -192,7 +191,7 @@ export const pythonInterpreterTool = {
} satisfies AiTool; } satisfies AiTool;
export async function runPythonInterpreter( export async function runPythonInterpreter(
rawArgs: string | AiJsonObject | undefined, rawArgs: unknown,
options: PythonInterpreterOptions = {}, options: PythonInterpreterOptions = {},
): Promise<PythonToolResult> { ): Promise<PythonToolResult> {
let args: PythonInterpreterArgs; let args: PythonInterpreterArgs;
@@ -203,7 +202,7 @@ export async function runPythonInterpreter(
return { return {
ok: false, ok: false,
phase: "internal", phase: "internal",
error: errorToString(error instanceof Error ? error : String(error)), error: errorToString(error),
}; };
} }
@@ -301,12 +300,13 @@ async function executePythonCode(
logger.info("execute.start", {args, options}); logger.info("execute.start", {args, options});
const pythonBinary = const pythonBinary =
options.pythonBinary ?? process.env.PYTHON_INTERPRETER_BINARY ?? "python"; options.pythonBinary ?? process.env.PYTHON_INTERPRETER_BINARY ?? "C:\\Users\\meloda\\Desktop\\AI_BOT\\.venv\\Scripts\\python.exe";
const timeoutMs = args.timeoutMs ?? options.executionTimeoutMs ?? DEFAULT_EXECUTION_TIMEOUT_MS; const timeoutMs = args.timeoutMs ?? options.executionTimeoutMs ?? DEFAULT_EXECUTION_TIMEOUT_MS;
const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS; const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
const tempDir = path.join(Environment.DATA_PATH, "cache", "python", "python-temp-" + randomUUID()); // const tempDir = path.join(Environment.DATA_PATH, "cache", "python", "python-temp-" + randomUUID());
const tempDir = path.join(Environment.FILE_TOOLS_ROOT_DIR ?? ".", "ollama-python-temp-" + randomUUID());
const inputDir = path.join(tempDir, PYTHON_INPUTS_DIR_NAME); const inputDir = path.join(tempDir, PYTHON_INPUTS_DIR_NAME);
const outputDir = path.join(tempDir, PYTHON_OUTPUTS_DIR_NAME); const outputDir = path.join(tempDir, PYTHON_OUTPUTS_DIR_NAME);
const attachmentsPath = path.join(tempDir, PYTHON_ATTACHMENTS_FILE_NAME); const attachmentsPath = path.join(tempDir, PYTHON_ATTACHMENTS_FILE_NAME);
@@ -350,12 +350,7 @@ async function executePythonCode(
}, },
}); });
logger.debug("process.done", { logger.debug("process.done", {duration: logger.duration(startedAt), exitCode: result.exitCode, timedOut: result.timedOut, outputTruncated: result.outputTruncated});
duration: logger.duration(startedAt),
exitCode: result.exitCode,
timedOut: result.timedOut,
outputTruncated: result.outputTruncated
});
if (result.timedOut) { if (result.timedOut) {
logger.warn("process.timeout", {duration: logger.duration(startedAt)}); logger.warn("process.timeout", {duration: logger.duration(startedAt)});
@@ -374,11 +369,7 @@ async function executePythonCode(
} }
if (result.outputTruncated) { if (result.outputTruncated) {
logger.warn("process.output_truncated", { logger.warn("process.output_truncated", {duration: logger.duration(startedAt), stdoutChars: result.stdout.length, stderrChars: result.stderr.length});
duration: logger.duration(startedAt),
stdoutChars: result.stdout.length,
stderrChars: result.stderr.length
});
return { return {
ok: false, ok: false,
@@ -433,17 +424,17 @@ async function executePythonCode(
skippedArtifacts, skippedArtifacts,
}; };
} catch (error) { } catch (error) {
logger.error("execute.failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); logger.error("execute.failed", {duration: logger.duration(startedAt), error});
return { return {
ok: false, ok: false,
phase: "internal", phase: "internal",
error: errorToString(error instanceof Error ? error : String(error)), error: errorToString(error),
}; };
} finally { } finally {
await rm(tempDir, { // await rm(tempDir, {
recursive: true, // recursive: true,
force: true, // force: true,
}); // });
} }
} }
@@ -661,7 +652,7 @@ function mimeTypeFromPath(filePath: string): string | undefined {
} }
function parsePythonInterpreterArgs( function parsePythonInterpreterArgs(
rawArgs: string | AiJsonObject | undefined, rawArgs: unknown,
options: PythonInterpreterOptions, options: PythonInterpreterOptions,
): PythonInterpreterArgs { ): PythonInterpreterArgs {
let args = rawArgs; let args = rawArgs;
@@ -674,11 +665,11 @@ function parsePythonInterpreterArgs(
} }
} }
if (!args || typeof args !== "object" || Array.isArray(args)) { if (!args || typeof args !== "object") {
throw new Error("Tool arguments must be an object."); throw new Error("Tool arguments must be an object.");
} }
const record = args as AiJsonObject; const record = args as Record<string, unknown>;
const code = record.code; const code = record.code;
if (typeof code !== "string" || !code.trim()) { if (typeof code !== "string" || !code.trim()) {
@@ -813,7 +804,7 @@ function buildSafeEnv(tempDir?: string): NodeJS.ProcessEnv {
}; };
} }
function errorToString(error: Error | string | object | null | undefined): string { function errorToString(error: unknown): string {
if (error instanceof Error) { if (error instanceof Error) {
return error.stack || error.message; return error.stack || error.message;
} }
+114 -216
View File
@@ -1,22 +1,13 @@
import {Environment} from "../../common/environment.js"; import {Environment} from "../../common/environment";
import {AiTool} from "../tool-types.js"; import {AiTool} from "../tool-types";
import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search.js"; import {braveSearchTool, webSearch} from "./brave-search";
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime.js"; import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime";
import {shellExecute, shellExecuteTool} from "./shell.js"; import {shellExecute, shellExecuteTool} from "./shell";
import {ToolHandler} from "./types.js"; import {ToolHandler} from "./types";
import {getWeather, getWeatherTool} from "./weather.js"; import {getWeather, getWeatherTool} from "./weather";
import {getMarketRates, getMarketRatesTool} from "./market-rates";
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator";
import { import {
GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
getFinancialMarketData,
getFinancialMarketDataToolPrompt,
getMarketRates
} from "./market-rates.js";
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator.js";
import {
beginFileWrite,
beginFileWriteTool,
cancelFileWrite,
cancelFileWriteTool,
copyPath, copyPath,
copyPathTool, copyPathTool,
createDirectory, createDirectory,
@@ -25,231 +16,138 @@ import {
createFileTool, createFileTool,
deletePath, deletePath,
deletePathTool, deletePathTool,
editFilePatch,
editFilePatchTool,
fileToolsToolPrompt,
finishFileWrite,
finishFileWriteTool,
listDirectory, listDirectory,
listDirectoryTool, listDirectoryTool,
readFile, readFile,
readFileTool, readFileTool,
renamePath, renamePath,
renamePathTool, renamePathTool,
searchFiles,
searchFilesTool,
sendFileAsAttachment,
sendFileAsAttachmentTool,
updateFile, updateFile,
updateFileTool, updateFileTool
writeFileChunk, } from "./file-system";
writeFileChunkTool import {createNote, createNoteTool} from "./create-note";
} from "./files.js"; import {
import {executeMemoryTool, memoryToolPrompt, memoryTools, type MemoryToolName} from "./user-memory.js"; deleteNote,
import {getMcpToolHandlers, getMcpToolPrompts, getMcpTools} from "../mcp/mcp-registry.js"; deleteNoteTool,
getNoteContent,
getNoteContentTool,
listNotes,
listNotesTool,
updateNoteContent,
updateNoteContentTool
} from "./list-notes";
import {getNoteFile, getNoteFileTool} from "./send-note-file";
import {searchNotes, searchNotesTool} from "./search-notes";
export const defaultTools: AiTool[] = [ export const getTools = () => {
const tools: AiTool[] = [
getCurrentDateTimeTool, getCurrentDateTimeTool,
getFinancialMarketData, getMarketRatesTool,
...memoryTools, createNoteTool,
]; listNotesTool,
getNoteContentTool,
updateNoteContentTool,
deleteNoteTool,
getNoteFileTool,
searchNotesTool
];
export const fileTools = [
readFileTool,
listDirectoryTool,
searchFilesTool,
createFileTool,
beginFileWriteTool,
writeFileChunkTool,
finishFileWriteTool,
cancelFileWriteTool,
sendFileAsAttachmentTool,
createDirectoryTool,
copyPathTool,
updateFileTool,
editFilePatchTool,
renamePathTool,
deletePathTool,
] satisfies AiTool[];
function parseToolNameSet(raw: string | undefined): Set<string> | undefined {
if (!raw?.trim()) return undefined;
const names = raw
.split(",")
.map(item => item.trim().toLowerCase())
.filter(Boolean);
return names.length ? new Set(names) : undefined;
}
function isLocalToolEnabled(toolName: string): boolean {
if (Environment.DISABLE_LOCAL_TOOLS) return false;
const allowlist = parseToolNameSet(Environment.LOCAL_TOOL_ALLOWLIST);
if (allowlist && !allowlist.has(toolName.toLowerCase())) return false;
const denylist = parseToolNameSet(Environment.LOCAL_TOOL_DENYLIST);
if (denylist && denylist.has(toolName.toLowerCase())) return false;
return true;
}
function filterEnabledTools(tools: AiTool[]): AiTool[] {
return tools.filter(tool => isLocalToolEnabled(tool.function.name));
}
export const getTools = (forCreator?: boolean) => {
const tools: AiTool[] = [];
if (Environment.DISABLE_LOCAL_TOOLS) {
tools.push(...getMcpTools());
return tools;
}
tools.push(...filterEnabledTools(defaultTools));
if (Environment.BRAVE_SEARCH_API_KEY) {
tools.push(...filterEnabledTools([webSearchTool]));
}
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
tools.push(...filterEnabledTools([getWeatherTool]));
}
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
tools.push(...filterEnabledTools(fileTools));
}
if (forCreator) {
if (Environment.ENABLE_PYTHON_INTERPRETER) { if (Environment.ENABLE_PYTHON_INTERPRETER) {
tools.push(...filterEnabledTools([pythonInterpreterTool])); tools.push(pythonInterpreterTool);
} }
if (Environment.ENABLE_UNSAFE_EVAL) { if (Environment.ENABLE_UNSAFE_EVAL) {
tools.push(...filterEnabledTools([shellExecuteTool])); tools.push(shellExecuteTool);
} }
if (Environment.BRAVE_SEARCH_API_KEY) {
tools.push(braveSearchTool);
}
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
tools.push(getWeatherTool);
}
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
tools.push(
readFileTool,
listDirectoryTool,
createFileTool,
createDirectoryTool,
updateFileTool,
renamePathTool,
copyPathTool,
deletePathTool,
);
} }
tools.push(...getMcpTools());
return tools; return tools;
}; // return [
// createNoteTool,
export const fileToolHandlers = { // listNotesTool,
read_file: readFile, // getNoteContentTool,
list_directory: listDirectory, // updateNoteContentTool,
search_files: searchFiles, // deleteNoteTool,
// getNoteFileTool,
create_file: createFile, // searchNotesTool
begin_file_write: beginFileWrite, // ];
write_file_chunk: writeFileChunk,
finish_file_write: finishFileWrite,
cancel_file_write: cancelFileWrite,
send_file_as_attachment: sendFileAsAttachment,
create_directory: createDirectory,
copy_path: copyPath,
update_file: updateFile,
edit_file_patch: editFilePatch,
rename_path: renamePath,
delete_path: deletePath,
}; };
export const getToolHandlers = () => { export const getToolHandlers = () => {
const handlers: Record<string, ToolHandler> = { let handlers: Record<string, ToolHandler> = {
...getMcpToolHandlers(), get_datetime: getCurrentDateTime,
get_market_rates: getMarketRates,
create_note: createNote,
list_notes: listNotes,
get_note_content: getNoteContent,
update_note_content: updateNoteContent,
delete_note: deleteNote,
get_note_file: getNoteFile,
search_notes: searchNotes
}; };
if (Environment.DISABLE_LOCAL_TOOLS) { if (Environment.ENABLE_PYTHON_INTERPRETER) {
return handlers; handlers = {
} python_interpreter: runPythonInterpreter,
...handlers
if (isLocalToolEnabled("get_datetime")) handlers.get_datetime = getCurrentDateTime;
if (isLocalToolEnabled("get_financial_market_data")) handlers.get_financial_market_data = getMarketRates;
for (const tool of memoryTools) {
if (!isLocalToolEnabled(tool.function.name)) continue;
handlers[tool.function.name] = async (args, context) => {
const userId = typeof args?.userId === "number" ? args.userId : undefined;
if (!userId) {
return {success: false, error: "Missing userId"};
}
return executeMemoryTool(tool.function.name as MemoryToolName, {
userId,
content: typeof args?.content === "string" ? args.content : undefined,
}, context);
}; };
} }
if (isLocalToolEnabled("read_file")) handlers.read_file = readFile; if (Environment.ENABLE_UNSAFE_EVAL) {
if (isLocalToolEnabled("list_directory")) handlers.list_directory = listDirectory; handlers = {
if (isLocalToolEnabled("search_files")) handlers.search_files = searchFiles; shell_execute: shellExecute,
if (isLocalToolEnabled("create_file")) handlers.create_file = createFile; ...handlers,
if (isLocalToolEnabled("begin_file_write")) handlers.begin_file_write = beginFileWrite; };
if (isLocalToolEnabled("write_file_chunk")) handlers.write_file_chunk = writeFileChunk; }
if (isLocalToolEnabled("finish_file_write")) handlers.finish_file_write = finishFileWrite;
if (isLocalToolEnabled("cancel_file_write")) handlers.cancel_file_write = cancelFileWrite;
if (isLocalToolEnabled("send_file_as_attachment")) handlers.send_file_as_attachment = sendFileAsAttachment;
if (isLocalToolEnabled("create_directory")) handlers.create_directory = createDirectory;
if (isLocalToolEnabled("copy_path")) handlers.copy_path = copyPath;
if (isLocalToolEnabled("update_file")) handlers.update_file = updateFile;
if (isLocalToolEnabled("edit_file_patch")) handlers.edit_file_patch = editFilePatch;
if (isLocalToolEnabled("rename_path")) handlers.rename_path = renamePath;
if (isLocalToolEnabled("delete_path")) handlers.delete_path = deletePath;
if (isLocalToolEnabled("python_interpreter")) handlers.python_interpreter = (args, _context) => runPythonInterpreter(args); if (Environment.BRAVE_SEARCH_API_KEY) {
if (isLocalToolEnabled("shell_execute")) handlers.shell_execute = shellExecute; handlers = {
if (isLocalToolEnabled("web_search")) handlers.web_search = webSearch; web_search: webSearch,
if (isLocalToolEnabled("get_weather")) handlers.get_weather = getWeather; ...handlers,
};
}
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
handlers = {
get_weather: getWeather,
...handlers,
};
}
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
handlers = {
read_file: readFile,
list_directory: listDirectory,
create_file: createFile,
create_directory: createDirectory,
update_file: updateFile,
rename_path: renamePath,
copy_path: copyPath,
delete_path: deletePath,
...handlers,
};
}
return handlers; return handlers;
}; };
export function getToolPrompts(toolNames: string[]): string[] {
if (Environment.DISABLE_LOCAL_TOOLS) {
return getMcpToolPrompts(toolNames);
}
const prompts: string[] = [];
const memoryToolNames = new Set(memoryTools.map(tool => tool.function.name));
let memoryPromptAdded = false;
for (const toolName of toolNames) {
if (!isLocalToolEnabled(toolName)) {
continue;
}
if (!prompts.includes(fileToolsToolPrompt) &&
fileTools.map(t => t.function.name).includes(toolName)) {
prompts.push(fileToolsToolPrompt);
continue;
}
if (memoryToolNames.has(toolName)) {
if (!memoryPromptAdded) {
prompts.push(memoryToolPrompt);
memoryPromptAdded = true;
}
continue;
}
switch (toolName) {
case GET_FINANCIAL_MARKET_DATA_TOOL_NAME:
prompts.push(getFinancialMarketDataToolPrompt);
break;
case WEB_SEARCH_TOOL_NAME:
prompts.push(webSearchToolPrompt);
break;
default:
break;
}
}
prompts.push(...getMcpToolPrompts(toolNames));
return prompts;
}
+10 -18
View File
@@ -1,29 +1,22 @@
import {getToolHandlers} from "./registry.js"; import {getToolHandlers} from "./registry";
import {normalizeToolArguments} from "./utils.js"; import {normalizeToolArguments} from "./utils";
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator.js"; import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator";
import {toolsLogger} from "./tool-logger.js"; import {toolsLogger} from "./tool-logger";
import {AiJsonObject, AiJsonValue} from "../tool-types.js";
import type {MemoryRuntimeContext} from "./user-memory.js";
import type {AiRuntimeTarget} from "../ai-runtime-target.js";
import type {AiProvider} from "../../model/ai-provider.js";
const logger = toolsLogger.child("runtime"); const logger = toolsLogger.child("runtime");
export type ToolRuntimeContext = { export type ToolRuntimeContext = {
pythonInputFiles?: PythonInterpreterInputFile[]; pythonInputFiles?: PythonInterpreterInputFile[];
provider?: AiProvider; };
runtimeTarget?: AiRuntimeTarget;
} & MemoryRuntimeContext;
function stringifyToolResult(result: AiJsonValue): string { function stringifyToolResult(result: unknown): string {
if (typeof result === "string") return result; if (typeof result === "string") return result;
return JSON.stringify(result, null, 2); return JSON.stringify(result, null, 2);
} }
export async function executeToolCall( export async function executeToolCall(
userId: number | undefined | null,
name: string, name: string,
args?: string | AiJsonObject, args?: unknown,
context: ToolRuntimeContext = {}, context: ToolRuntimeContext = {},
): Promise<string> { ): Promise<string> {
const startedAt = Date.now(); const startedAt = Date.now();
@@ -38,7 +31,7 @@ export async function executeToolCall(
try { try {
if (name === PYTHON_INTERPRETER_TOOL_NAME) { if (name === PYTHON_INTERPRETER_TOOL_NAME) {
const result = await runPythonInterpreter(normalizeToolArguments(args, userId), { const result = await runPythonInterpreter(normalizeToolArguments(args), {
executionTimeoutMs: 8_000, executionTimeoutMs: 8_000,
syntaxTimeoutMs: 3_000, syntaxTimeoutMs: 3_000,
maxCodeChars: 100_000, maxCodeChars: 100_000,
@@ -52,13 +45,12 @@ export async function executeToolCall(
return s; return s;
} }
const arguments1 = normalizeToolArguments(args, userId); const result = await handler(normalizeToolArguments(args));
const result = await handler(arguments1, context);
const s = stringifyToolResult(result); const s = stringifyToolResult(result);
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)}); logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
return s; return s;
} catch (error) { } catch (error) {
logger.error("execute.failed", {name, duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); logger.error("execute.failed", {name, duration: logger.duration(startedAt), error});
return stringifyToolResult({ return stringifyToolResult({
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}); });
+6 -7
View File
@@ -1,10 +1,9 @@
import {AiTool} from "../tool-types.js"; import {AiTool} from "../tool-types";
import path from "node:path"; import path from "node:path";
import {readdir, readFile} from "node:fs/promises"; import {readdir, readFile} from "node:fs/promises";
import {notesDir, notesRootFile} from "../../index.js"; import {notesDir, notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils.js"; import {asNonEmptyString} from "./utils";
import {toolsLogger} from "./tool-logger.js"; import {toolsLogger} from "./tool-logger";
import {AiJsonObject, AiJsonValue} from "../tool-types.js";
const logger = toolsLogger.child("search-notes"); const logger = toolsLogger.child("search-notes");
@@ -53,7 +52,7 @@ export const searchNotesTool = {
} satisfies AiTool; } satisfies AiTool;
export async function searchNotes( export async function searchNotes(
args?: AiJsonObject, args?: Record<string, unknown>,
): Promise<SearchNotesResult> { ): Promise<SearchNotesResult> {
const startedAt = Date.now(); const startedAt = Date.now();
logger.debug("start", {args}); logger.debug("start", {args});
@@ -140,7 +139,7 @@ export async function searchNotes(
} }
} }
function parseSearchLimit(value: AiJsonValue | undefined): number { function parseSearchLimit(value: unknown): number {
const parsed = const parsed =
typeof value === "number" typeof value === "number"
? value ? value
+113
View File
@@ -0,0 +1,113 @@
import {AiTool} from "../tool-types";
import path from "node:path";
import {readFile, stat} from "node:fs/promises";
import {notesRootFile} from "../../index";
import {asNonEmptyString} from "./utils";
import {buildSafeNoteFilePath} from "./list-notes";
import z from "zod";
import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("get-note-file");
export type NoteFileAttachment = {
type: "local_file";
fileName: string;
// filePath: string;
relativePath: string;
mimeType: "text/markdown";
sizeBytes: number;
};
export type GetNoteFileResult =
| {
success: true;
attachment: NoteFileAttachment;
} | { success: false; error: string };
export const NoteFileAttachmentSchema = z.object({
type: z.literal("local_file"),
fileName: z.string(),
// filePath: z.string(),
relativePath: z.string(),
mimeType: z.literal("text/markdown"),
sizeBytes: z.number(),
});
export const GetNoteFileResultSchema = z.discriminatedUnion("success", [
z.object({
success: z.literal(true),
attachment: NoteFileAttachmentSchema,
}),
z.object({
success: z.literal(false),
error: z.string(),
}),
]);
export const getNoteFileTool = {
type: "function",
function: {
name: "get_note_file",
description:
"Prepare a Markdown note file to be sent to the user as a .md attachment. Returns a local file descriptor that the host application should use to upload or send the file.",
parameters: {
type: "object",
properties: {
fileName: {
type: "string",
description:
"The file name of the note to send. It may be provided with or without the .md extension. Must not contain forbidden or unsafe characters such as /, \\, :, *, ?, \", <, >, |, or control characters.",
},
},
required: ["fileName"],
},
},
} satisfies AiTool;
export async function getNoteFile(
args?: Record<string, unknown>,
): Promise<GetNoteFileResult> {
logger.debug("start", {args});
const fileName = asNonEmptyString(args?.fileName) ?? "";
if (!fileName.trim().length) {
return {success: false, error: "No file name provided"};
}
const noteFilePath = buildSafeNoteFilePath(fileName);
if (!noteFilePath) {
return {success: false, error: "Invalid or unsafe file name provided"};
}
try {
// Проверяем, что файл существует и действительно читается.
await readFile(noteFilePath, "utf-8");
const fileStat = await stat(noteFilePath);
if (!fileStat.isFile()) {
return {success: false, error: "Note path is not a file"};
}
const normalizedFileName = path.basename(noteFilePath);
const relativePath = path.relative(path.dirname(notesRootFile), noteFilePath);
const result: GetNoteFileResult = {
success: true,
attachment: {
type: "local_file",
fileName: normalizedFileName,
// filePath: noteFilePath,
relativePath,
mimeType: "text/markdown",
sizeBytes: fileStat.size,
},
};
logger.debug("done", {fileName: result.attachment.fileName, relativePath: result.attachment.relativePath, sizeBytes: result.attachment.sizeBytes});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {success: false, error: `Failed to prepare note file: ${errorMessage}`};
}
}
+5 -6
View File
@@ -1,13 +1,12 @@
import {AiTool} from "../tool-types"; import {AiTool} from "../tool-types";
import {runCommand} from "../../util/utils.js"; import {runCommand} from "../../util/utils";
import {asNonEmptyString} from "./utils.js"; import {asNonEmptyString} from "./utils";
import {AiJsonObject} from "../tool-types";
export const shellExecuteTool = { export const shellExecuteTool = {
type: "function", type: "function",
function: { function: {
name: "shell_execute", name: "shell_execute",
description: "Execute NON-Python command in a shell. Do not use if you intend to execute some python.", description: "Execute command in a shell",
parameters: { parameters: {
type: "object", type: "object",
properties: { properties: {
@@ -33,7 +32,7 @@ export const shellExecuteToolPrompt = [
"- The server shell may be Linux/macOS shell, Windows CMD, or Windows PowerShell.", "- The server shell may be Linux/macOS shell, Windows CMD, or Windows PowerShell.",
"- Do not assume Bash/Linux commands are available.", "- Do not assume Bash/Linux commands are available.",
"- Do not assume Windows commands are available.", "- Do not assume Windows commands are available.",
"- If the current OS/shell is unclear, first run a safe environment inspection command.", "- If the current OS/shell is unknown, first run a safe environment inspection command.",
"- Safe OS inspection examples:", "- Safe OS inspection examples:",
" - Node.js: `node -p \"process.platform\"`", " - Node.js: `node -p \"process.platform\"`",
" - Node.js: `node -p \"process.cwd()\"`", " - Node.js: `node -p \"process.cwd()\"`",
@@ -100,7 +99,7 @@ export const shellExecuteToolPrompt = [
"", "",
].join("\n"); ].join("\n");
export async function shellExecute(args?: AiJsonObject): Promise<string | undefined | null> { export async function shellExecute(args?: Record<string, unknown>): Promise<string | undefined | null> {
const cmd = asNonEmptyString(args?.cmd); const cmd = asNonEmptyString(args?.cmd);
if (!cmd) return undefined; if (!cmd) return undefined;
+1 -1
View File
@@ -1,3 +1,3 @@
import {appLogger} from "../../logging/logger.js"; import {appLogger} from "../../logging/logger";
export const toolsLogger = appLogger.child("ai-tools"); export const toolsLogger = appLogger.child("ai-tools");
+1 -4
View File
@@ -1,4 +1 @@
import {AiJsonObject, AiJsonValue} from "../tool-types"; export type ToolHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown;
import type {ToolRuntimeContext} from "./runtime.js";
export type ToolHandler = (args?: AiJsonObject, context?: ToolRuntimeContext) => Promise<AiJsonValue | string | null | undefined> | AiJsonValue | string | null | undefined;
-582
View File
@@ -1,582 +0,0 @@
import path from "node:path";
import {readFile, rename, writeFile, mkdir, rm} from "node:fs/promises";
import {AiProvider} from "../../model/ai-provider.js";
import {Environment} from "../../common/environment.js";
import {createMistralClient, createOllamaClient, createOpenAiClient, resolveOptionalAiRuntimeTarget, type AiRuntimeTarget} from "../ai-runtime-target.js";
import {AiTool} from "../tool-types.js";
import {toolsLogger} from "./tool-logger.js";
import {asNonEmptyString} from "./utils.js";
const logger = toolsLogger.child("user-memory");
function memoryDir(): string {
return path.join(Environment.DATA_PATH, "memory");
}
export const USER_MEMORY_MAX_CHARS = 1000;
export type MemoryScope = "user" | "system";
export type MemoryAction = "add" | "replace" | "remove";
export type MemoryRuntimeContext = {
provider?: AiProvider;
runtimeTarget?: AiRuntimeTarget;
};
export type MemoryOperationResult =
| {success: true; scope: MemoryScope; filePath: string; content: string; chars: number; compressed: boolean}
| {success: false; scope: MemoryScope; error: string};
type CompressionRunResult = {
content: string;
};
export type MemoryCompressionRunner = (params: {
target: AiRuntimeTarget;
scope: MemoryScope;
currentText: string;
limit: number;
}) => Promise<string>;
function extractMistralText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map(part => {
if (typeof part === "string") return part;
if (part && typeof part === "object" && "text" in part && typeof (part as {text?: unknown}).text === "string") {
return (part as {text: string}).text;
}
return "";
})
.join("");
}
export type MemoryToolName =
| "read_user_info"
| "read_system_info"
| "add_user_info"
| "add_system_info"
| "remove_user_info"
| "remove_system_info"
| "replace_user_info"
| "replace_system_info"
| "delete_user_info"
| "delete_system_info";
export const MEMORY_TOOL_NAMES: MemoryToolName[] = [
"read_user_info",
"read_system_info",
"add_user_info",
"add_system_info",
"remove_user_info",
"remove_system_info",
"replace_user_info",
"replace_system_info",
"delete_user_info",
"delete_system_info",
];
type MemoryToolSpec = {
name: MemoryToolName;
scope: MemoryScope;
kind: "read" | "write" | "delete";
action?: MemoryAction;
description: string;
prompt: string;
};
const MEMORY_TOOL_SPECS: MemoryToolSpec[] = [
{
name: "read_user_info",
scope: "user",
kind: "read",
description: "Read persistent user memory from user.md.",
prompt: `Use when you need to inspect remembered user facts before editing or answering.`,
},
{
name: "read_system_info",
scope: "system",
kind: "read",
description: "Read persistent assistant memory from system.md.",
prompt: `Use when you need to inspect remembered assistant instructions before editing or answering.`,
},
{
name: "add_user_info",
scope: "user",
kind: "write",
action: "add",
description: "Append a durable fact about the user to user.md.",
prompt: `Use for new user facts, preferences, identity details, and profile information. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "add_system_info",
scope: "system",
kind: "write",
action: "add",
description: "Append a durable instruction about the assistant to system.md.",
prompt: `Use for new assistant identity, style, or behavior instructions. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "remove_user_info",
scope: "user",
kind: "write",
action: "remove",
description: "Remove a specific user fact or fragment from user.md.",
prompt: `Use when the user asks to forget something about themselves. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "remove_system_info",
scope: "system",
kind: "write",
action: "remove",
description: "Remove a specific assistant instruction or fragment from system.md.",
prompt: `Use when the user asks to forget something about the assistant. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "replace_user_info",
scope: "user",
kind: "write",
action: "replace",
description: "Replace user.md completely with a new compact version.",
prompt: `Use when the user wants to overwrite all remembered user info, such as "forget everything about me and remember only this". Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "replace_system_info",
scope: "system",
kind: "write",
action: "replace",
description: "Replace system.md completely with a new compact version.",
prompt: `Use when the user wants to overwrite all remembered assistant info or instructions. Keep the result at or below ${USER_MEMORY_MAX_CHARS} characters.`,
},
{
name: "delete_user_info",
scope: "user",
kind: "delete",
description: "Delete the user memory file user.md.",
prompt: `Use when the user asks to delete all remembered user info and remove the memory file entirely.`,
},
{
name: "delete_system_info",
scope: "system",
kind: "delete",
description: "Delete the assistant memory file system.md.",
prompt: `Use when the user asks to delete all remembered assistant info and remove the memory file entirely.`,
},
];
export const memoryToolPrompt = [
"Use the memory tools to manage persistent per-user memory.",
"- `read_*` shows the current file content before editing.",
"- `user.md` stores durable facts about the user.",
"- `system.md` stores durable facts/instructions about the assistant itself.",
"- `add_*` appends a new fact or instruction.",
"- `remove_*` removes a specific fact or fragment.",
"- `replace_*` rewrites the whole file when the user wants to overwrite memory.",
"- `delete_*` removes the file entirely.",
`- Keep each file at or below ${USER_MEMORY_MAX_CHARS} characters.`,
].join("\n");
function createMemoryTool(spec: MemoryToolSpec): AiTool {
return {
type: "function",
function: {
name: spec.name,
description: spec.description,
parameters: {
type: "object",
properties: spec.kind === "read" || spec.kind === "delete" ? {} : {
content: {
type: "string",
description: spec.action === "remove"
? "Exact text or fragment to remove from memory."
: "Text to append or replace in memory.",
},
},
required: spec.kind === "read" || spec.kind === "delete" ? [] : ["content"],
},
},
} satisfies AiTool;
}
export const memoryTools = MEMORY_TOOL_SPECS.map(createMemoryTool);
function normalizeUserId(userId: number): number | null {
return Number.isSafeInteger(userId) && userId > 0 ? userId : null;
}
function normalizeMemoryText(value: string): string {
return value.replaceAll("\r\n", "\n");
}
function getMemoryUserDir(userId: number): string {
return path.join(memoryDir(), String(userId));
}
export function getMemoryFilePath(userId: number, scope: MemoryScope): string {
return path.join(getMemoryUserDir(userId), `${scope}.md`);
}
async function ensureMemoryDir(userId: number): Promise<string> {
const dir = getMemoryUserDir(userId);
await mkdir(dir, {recursive: true});
return dir;
}
async function readMemoryFile(userId: number, scope: MemoryScope): Promise<string> {
const filePath = getMemoryFilePath(userId, scope);
try {
return normalizeMemoryText(await readFile(filePath, "utf-8"));
} catch (error) {
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
return "";
}
throw error;
}
}
async function writeMemoryFile(userId: number, scope: MemoryScope, content: string): Promise<string> {
const normalized = normalizeMemoryText(content);
const filePath = getMemoryFilePath(userId, scope);
await ensureMemoryDir(userId);
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
await writeFile(tempPath, normalized, "utf-8");
await rename(tempPath, filePath);
return filePath;
}
function trimToLimit(content: string, limit = USER_MEMORY_MAX_CHARS): string {
if (content.length <= limit) return content;
return content.slice(0, limit).trimEnd();
}
function stripCodeFences(content: string): string {
const trimmed = content.trim();
const fenced = trimmed.match(/^```(?:markdown|md)?\s*([\s\S]*?)\s*```$/i);
if (fenced?.[1]) return fenced[1].trim();
return trimmed;
}
function sameTarget(left: AiRuntimeTarget | undefined, right: AiRuntimeTarget | undefined): boolean {
if (!left || !right) return false;
return left.provider === right.provider
&& left.model === right.model
&& (left.baseUrl ?? "") === (right.baseUrl ?? "")
&& (left.apiKey ?? "") === (right.apiKey ?? "");
}
async function compressWithTarget(params: {
target: AiRuntimeTarget;
scope: MemoryScope;
currentText: string;
limit: number;
}): Promise<CompressionRunResult> {
const {target, scope, currentText, limit} = params;
const systemPrompt = [
"You compress persistent memory for a chat bot.",
"Return only the rewritten Markdown text.",
"Preserve important facts, preferences, identities, instructions, and durable context.",
"Remove noise, duplication, stale details, and low-value filler.",
`Keep the result at or below ${limit} characters.`,
"Do not add explanations, bullet labels, or code fences.",
].join("\n");
const userPrompt = [
`Memory scope: ${scope}`,
`Character limit: ${limit}`,
"Current memory:",
currentText.trim() || "(empty)",
"",
"Rewrite it as compact Markdown only.",
].join("\n");
logger.info("compress.start", {provider: target.provider, model: target.model, scope, chars: currentText.length});
switch (target.provider) {
case AiProvider.OPENAI: {
const client = createOpenAiClient(target);
const response = await client.chat.completions.create({
model: target.model,
temperature: 0,
messages: [
{role: "system", content: systemPrompt},
{role: "user", content: userPrompt},
],
});
const text = response.choices[0]?.message?.content ?? "";
return {content: stripCodeFences(text)};
}
case AiProvider.MISTRAL: {
const client = createMistralClient(target);
const response = await client.chat.complete({
model: target.model,
temperature: 0,
messages: [
{role: "system", content: systemPrompt},
{role: "user", content: userPrompt},
],
} as Parameters<typeof client.chat.complete>[0]);
const text = extractMistralText(response.choices?.[0]?.message?.content);
return {content: stripCodeFences(text)};
}
case AiProvider.OLLAMA: {
const client = createOllamaClient(target);
const response = await client.chat({
model: target.model,
stream: false,
options: {temperature: 0},
messages: [
{role: "system", content: systemPrompt},
{role: "user", content: userPrompt},
],
});
const text = typeof response.message?.content === "string" ? response.message.content : "";
return {content: stripCodeFences(text)};
}
}
}
export async function compressMemoryWithFallback(params: {
provider?: AiProvider;
currentTarget?: AiRuntimeTarget;
scope: MemoryScope;
currentText: string;
limit?: number;
}, runner: MemoryCompressionRunner = async (input) => (await compressWithTarget(input)).content): Promise<{content: string; compressed: boolean; usedTarget?: AiRuntimeTarget}> {
const limit = params.limit ?? USER_MEMORY_MAX_CHARS;
const trimmed = normalizeMemoryText(params.currentText);
if (trimmed.length <= limit) {
return {content: trimmed, compressed: false};
}
const explicitTarget = params.provider ? resolveOptionalAiRuntimeTarget(params.provider, "memoryCompress") : undefined;
const targets = [explicitTarget, params.currentTarget].filter((target, index, list): target is AiRuntimeTarget => !!target && list.findIndex(item => sameTarget(item, target)) === index);
for (const target of targets) {
try {
const content = trimToLimit(await runner({target, scope: params.scope, currentText: trimmed, limit}), limit);
if (content.length <= limit) {
return {content, compressed: true, usedTarget: target};
}
} catch (error) {
logger.warn("compress.failed", {
provider: params.provider,
scope: params.scope,
target: target.model,
error: error instanceof Error ? error.message : String(error),
});
}
}
return {content: trimToLimit(trimmed, limit), compressed: true};
}
async function compressMemoryIfNeeded(params: {
userId: number;
scope: MemoryScope;
content: string;
context?: MemoryRuntimeContext;
limit?: number;
}): Promise<{content: string; compressed: boolean}> {
const {scope, context, limit = USER_MEMORY_MAX_CHARS} = params;
const result = await compressMemoryWithFallback({
provider: context?.provider,
currentTarget: context?.runtimeTarget,
scope,
currentText: params.content,
limit,
});
if (!result.compressed) {
return result;
}
if (result.content.length > limit) {
return {content: trimToLimit(result.content, limit), compressed: true};
}
return {content: result.content, compressed: true};
}
async function finalizeMemoryWrite(params: {
userId: number;
scope: MemoryScope;
content: string;
context?: MemoryRuntimeContext;
}): Promise<{filePath: string; content: string; compressed: boolean}> {
const {userId, scope, context} = params;
const compressed = await compressMemoryIfNeeded({userId, scope, content: params.content, context});
const filePath = await writeMemoryFile(userId, scope, compressed.content);
return {filePath, content: compressed.content, compressed: compressed.compressed};
}
function findMemoryToolSpec(toolName: string): MemoryToolSpec | undefined {
return MEMORY_TOOL_SPECS.find(spec => spec.name === toolName);
}
function isMemoryWriteTool(spec: MemoryToolSpec): spec is MemoryToolSpec & {kind: "write"; action: MemoryAction} {
return spec.kind === "write";
}
export async function buildUserMemoryPrompt(userId: number | undefined | null): Promise<string | undefined> {
const normalizedUserId = typeof userId === "number" ? normalizeUserId(userId) : null;
if (!normalizedUserId) return undefined;
const [userMemoryResult, systemMemoryResult] = await Promise.all([
readUserMemory(normalizedUserId, "user"),
readUserMemory(normalizedUserId, "system"),
]);
const userMemory = userMemoryResult.success ? userMemoryResult.content : "";
const systemMemory = systemMemoryResult.success ? systemMemoryResult.content : "";
const blocks: string[] = [];
if (systemMemory.trim()) {
blocks.push([
"## Assistant memory (system.md)",
"This is information about the assistant and its behavior.",
systemMemory.trim(),
].join("\n"));
}
if (userMemory.trim()) {
blocks.push([
"## User memory (user.md)",
"This is information about the user.",
userMemory.trim(),
].join("\n"));
}
return blocks.length ? blocks.join("\n\n") : undefined;
}
export async function readUserMemory(userId: number, scope: MemoryScope): Promise<MemoryOperationResult> {
const normalizedUserId = normalizeUserId(userId);
if (!normalizedUserId) {
return {success: false, scope, error: "Invalid userId"};
}
try {
const content = await readMemoryFile(normalizedUserId, scope);
return {
success: true,
scope,
filePath: getMemoryFilePath(normalizedUserId, scope),
content,
chars: content.length,
compressed: false,
};
} catch (error) {
return {success: false, scope, error: error instanceof Error ? error.message : String(error)};
}
}
export async function updateUserMemory(args: {
userId: number;
scope: MemoryScope;
action: MemoryAction;
content?: string;
context?: MemoryRuntimeContext;
}): Promise<MemoryOperationResult> {
const normalizedUserId = normalizeUserId(args.userId);
if (!normalizedUserId) {
return {success: false, scope: args.scope, error: "Invalid userId"};
}
try {
const current = await readMemoryFile(normalizedUserId, args.scope);
let next = current;
switch (args.action) {
case "add": {
const content = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
if (!content.trim()) {
return {success: false, scope: args.scope, error: "No content provided"};
}
next = [current.trimEnd(), content.trim()].filter(Boolean).join(current.trim() ? "\n\n" : "");
break;
}
case "replace": {
const content = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
next = content;
break;
}
case "remove": {
const needle = normalizeMemoryText(asNonEmptyString(args.content) ?? "");
if (!needle.trim()) {
return {success: false, scope: args.scope, error: "No text to remove provided"};
}
if (!current.includes(needle)) {
return {success: false, scope: args.scope, error: "Text not found in memory"};
}
next = current.split(needle).join("").trim();
break;
}
}
const finalized = await finalizeMemoryWrite({userId: normalizedUserId, scope: args.scope, content: next, context: args.context});
logger.debug("write.done", {
userId: normalizedUserId,
scope: args.scope,
chars: finalized.content.length,
compressed: finalized.compressed,
filePath: finalized.filePath,
});
return {
success: true,
scope: args.scope,
filePath: finalized.filePath,
content: finalized.content,
chars: finalized.content.length,
compressed: finalized.compressed,
};
} catch (error) {
return {success: false, scope: args.scope, error: error instanceof Error ? error.message : String(error)};
}
}
export async function executeMemoryTool(toolName: MemoryToolName, args: {userId: number; content?: string}, context?: MemoryRuntimeContext): Promise<MemoryOperationResult> {
const spec = findMemoryToolSpec(toolName);
if (!spec) {
return {success: false, scope: "user", error: `Unknown memory tool: ${toolName}`};
}
if (spec.kind === "read") {
return readUserMemory(args.userId, spec.scope);
}
if (spec.kind === "delete") {
return deleteUserMemory(args.userId, spec.scope);
}
if (!isMemoryWriteTool(spec)) {
return {success: false, scope: spec.scope, error: `Unsupported memory tool: ${toolName}`};
}
return updateUserMemory({
userId: args.userId,
scope: spec.scope,
action: spec.action,
content: args.content,
context,
});
}
export async function deleteUserMemory(userId: number, scope: MemoryScope): Promise<MemoryOperationResult> {
const normalizedUserId = normalizeUserId(userId);
if (!normalizedUserId) {
return {success: false, scope, error: "Invalid userId"};
}
const filePath = getMemoryFilePath(normalizedUserId, scope);
try {
await rm(filePath, {force: true});
return {success: true, scope, filePath, content: "", chars: 0, compressed: false};
} catch (error) {
return {success: false, scope, error: error instanceof Error ? error.message : String(error)};
}
}
+35 -18
View File
@@ -1,25 +1,24 @@
import {Ollama} from "ollama"; import {Ollama} from "ollama";
import {toolsLogger} from "./tool-logger.js"; import {z} from "zod";
import {AiJsonObject, AiJsonValue} from "../tool-types"; import {toolsLogger} from "./tool-logger";
import type {BoundaryValue} from "../../common/boundary-types";
const logger = toolsLogger.child("utils"); const logger = toolsLogger.child("utils");
export function asNonEmptyString(value: BoundaryValue): string | undefined { export function asNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 return typeof value === "string" && value.trim().length > 0
? value.trim() ? value.trim()
: undefined; : undefined;
} }
export function normalizeToolArguments(args: string | AiJsonObject | undefined, userId?: number | null): AiJsonObject { export function normalizeToolArguments(args: unknown): Record<string, unknown> {
if (!args) return {}; if (!args) return {};
if (typeof args === "string") { if (typeof args === "string") {
try { try {
const parsed = JSON.parse(args) as AiJsonValue; const parsed = JSON.parse(args);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as AiJsonObject; return parsed as Record<string, unknown>;
} }
} catch { } catch {
return { return {
@@ -31,17 +30,13 @@ export function normalizeToolArguments(args: string | AiJsonObject | undefined,
} }
if (typeof args === "object" && !Array.isArray(args)) { if (typeof args === "object" && !Array.isArray(args)) {
const userIdObject = userId ? {"userId": userId} : {}; return args as Record<string, unknown>;
return {
...args,
...userIdObject,
} as AiJsonObject;
} }
return {}; return {};
} }
export function asBoolean(value: BoundaryValue, defaultValue = false): boolean { export function asBoolean(value: unknown, defaultValue = false): boolean {
if (typeof value === "boolean") return value; if (typeof value === "boolean") return value;
if (typeof value === "string") { if (typeof value === "string") {
@@ -54,11 +49,11 @@ export function asBoolean(value: BoundaryValue, defaultValue = false): boolean {
return defaultValue; return defaultValue;
} }
export function asString(value: BoundaryValue, defaultValue = ""): string { export function asString(value: unknown, defaultValue = ""): string {
return typeof value === "string" ? value : defaultValue; return typeof value === "string" ? value : defaultValue;
} }
export function asPositiveInt(value: BoundaryValue, defaultValue: number, maxValue: number): number { export function asPositiveInt(value: unknown, defaultValue: number, maxValue: number): number {
const n = typeof value === "number" const n = typeof value === "number"
? value ? value
: typeof value === "string" : typeof value === "string"
@@ -89,7 +84,7 @@ export async function unloadAllOllamaModels(ollama: Ollama, exceptFor?: string[]
await Promise.all(unloadPromises); await Promise.all(unloadPromises);
logger.info("ollama.unload_all.done", {count: modelsToUnload.length, exceptFor}); logger.info("ollama.unload_all.done", {count: modelsToUnload.length, exceptFor});
} catch (error) { } catch (error) {
logger.error("ollama.unload_all.failed", {exceptFor, error: error instanceof Error ? error : String(error)}); logger.error("ollama.unload_all.failed", {exceptFor, error});
} }
} }
@@ -106,8 +101,30 @@ export async function loadOllamaModel(model: string, ollama: Ollama, contextLeng
}); });
logger.info("ollama.load.done", {model, contextLength}); logger.info("ollama.load.done", {model, contextLength});
return true; return true;
} catch (error) { } catch (e: unknown) {
logger.error("ollama.load.failed", {model, contextLength, error: error instanceof Error ? error : String(error)}); logger.error("ollama.load.failed", {model, contextLength, error: e});
return false; return false;
} }
} }
export type ToolPlanStep = {
t: string;
h: string;
from: string;
};
export type RouterPlan = {
s: ToolPlanStep[];
m: string;
};
export const ToolPlanStepSchema = z.object({
t: z.string(),
h: z.string(),
from: z.string(),
});
export const RouterPlanSchema = z.object({
s: z.array(ToolPlanStepSchema),
m: z.string()
});
+9 -9
View File
@@ -1,11 +1,11 @@
import axios from "axios"; import axios from "axios";
import {toolsLogger} from "./tool-logger.js"; import {toolsLogger} from "./tool-logger";
const logger = toolsLogger.child("weather"); const logger = toolsLogger.child("weather");
import {Environment} from "../../common/environment.js"; import {Environment} from "../../common/environment";
import {logError} from "../../util/utils.js"; import {logError} from "../../util/utils";
import {AiJsonObject, AiTool} from "../tool-types.js"; import {AiTool} from "../tool-types";
import {asNonEmptyString} from "./utils.js"; import {asNonEmptyString} from "./utils";
export const getWeatherTool = { export const getWeatherTool = {
type: "function", type: "function",
@@ -45,7 +45,7 @@ export const weatherToolPrompt = [
"If the city is missing or unclear, ask the user to specify it.", "If the city is missing or unclear, ask the user to specify it.",
].join("\n"); ].join("\n");
export async function getWeather(args?: AiJsonObject): Promise<AiJsonObject | null> { export async function getWeather(args?: Record<string, unknown>): Promise<Record<string, unknown> | null> {
const startedAt = Date.now(); const startedAt = Date.now();
logger.info("start", {args}); logger.info("start", {args});
try { try {
@@ -141,9 +141,9 @@ export async function getWeather(args?: AiJsonObject): Promise<AiJsonObject | nu
windSpeed: wind.speed, windSpeed: wind.speed,
}, },
}; };
} catch (error) { } catch (e: unknown) {
logger.error("failed", {duration: logger.duration(startedAt), error: error instanceof Error ? error : String(error)}); logger.error("failed", {duration: logger.duration(startedAt), error: e});
logError(error instanceof Error ? error : String(error)); logError(e);
return null; return null;
} finally { } finally {
logger.debug("done", {duration: logger.duration(startedAt)}); logger.debug("done", {duration: logger.duration(startedAt)});
-42
View File
@@ -1,42 +0,0 @@
import {AiProvider} from "../model/ai-provider";
import type {StoredAttachment} from "../model/stored-attachment";
import type {AiDownloadedFile} from "./telegram-attachments";
import {persistInternalJsonArtifactAttachment} from "./internal-artifact-store";
export async function persistTranscriptArtifactAttachment(params: {
provider: AiProvider;
transcript: string;
downloads: AiDownloadedFile[];
chatId: number;
messageId: number;
}): Promise<StoredAttachment | undefined> {
const text = params.transcript.trim();
if (!text) return Promise.resolve(undefined);
const sources = params.downloads
.filter(download => download.kind === "audio" || download.kind === "video-note")
.map(download => ({
fileId: download.fileId,
fileName: download.fileName,
mimeType: download.mimeType,
sizeBytes: download.sizeBytes ?? download.buffer.length,
sha256: download.sha256,
}));
return await persistInternalJsonArtifactAttachment({
artifactKind: "transcript",
fileNamePrefix: "transcript",
chatId: params.chatId,
messageId: params.messageId,
payload: {
provider: params.provider,
transcript: text,
sources,
},
metadata: {
provider: params.provider,
sourceFileNames: sources.map(source => source.fileName),
transcriptChars: text.length,
},
});
}
-373
View File
@@ -1,373 +0,0 @@
import {AiProvider} from "../model/ai-provider";
import {AI_VOICE_MODE_TRANSCRIPT, DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
import {Environment} from "../common/environment";
import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline";
import {PipelineFallbackNotifier} from "./user-request-pipeline/fallback-notifier";
import {buildToolRankFallbackTargetDetails} from "./user-request-pipeline/fallback-target-details";
import {mergeReplyChainDownloads, shouldPreferCurrentDownloads} from "./reply-chain-downloads";
import {attachmentsToDownloadedFiles, type AiDownloadedFile} from "./telegram-attachments";
import type {TelegramStreamMessage} from "./telegram-stream-message";
import type {ChatMessage} from "./chat-messages-types";
import type {OpenAIChatMessage} from "./openai-chat-message";
import type {MistralChatMessage} from "./mistral-chat-message";
import type {PreparedDocumentRag} from "./document-rag-pipeline";
import {prepareDocumentRag} from "./document-rag-pipeline";
import {persistRagArtifactAttachment} from "./rag-artifact-store";
import {persistTranscriptArtifactAttachment} from "./transcript-artifact-store";
import type {ToolRuntimeContext} from "./tools/runtime";
import {recordPipelineFallback, recordRagRun} from "../common/ai-observability.js";
import {
appendTranscriptToChatMessages,
collectTextMessages,
initialStatus,
providerName,
RuntimeConfigSnapshot,
stripAudioFromRunnerMessages,
toolRuntimeContextFromDownloads,
transcribeAudioIfNeeded,
collectStoredReplyChainAttachments,
UnifiedRunOptions,
} from "./unified-ai-runner.shared";
import {aiLog} from "../logging/ai-logger";
import {isTranscribableAudioDownload} from "./speech-to-text";
export type PreparedUnifiedAiRequest = {
chatMessages: Array<OpenAIChatMessage | MistralChatMessage | ChatMessage>;
imageCount: number;
firstRoundStatus: string;
toolContext: ToolRuntimeContext;
preparedDocumentRag?: PreparedDocumentRag;
finishAfterTranscript: boolean;
cleanup: () => Promise<void>;
};
type MutablePreparedContext = {
chatMessages: Array<OpenAIChatMessage | MistralChatMessage | ChatMessage>;
imageCount: number;
firstRoundStatus: string;
toolContext: ToolRuntimeContext;
transcript: string;
preparedDocumentRag?: PreparedDocumentRag;
finishAfterTranscript: boolean;
};
function nowIso(): string {
return new Date().toISOString();
}
function runtimeTargetFor(options: UnifiedRunOptions, config: RuntimeConfigSnapshot) {
return options.provider === AiProvider.OLLAMA
? config.ollamaChatTarget
: options.provider === AiProvider.MISTRAL
? config.mistralChatTarget
: config.openAiChatTarget;
}
function createAiRequestPipelineState(options: UnifiedRunOptions): UserRequestPipelineState {
return {
requestId: options.requestId ?? `ai:${options.msg.chat.id}:${options.msg.message_id}:${Date.now()}`,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
replyToMessageId: options.msg.reply_to_message?.message_id,
fromId: options.msg.from?.id ?? 0,
receivedAt: nowIso(),
text: options.text,
settings: {
provider: options.provider,
responseLanguage: options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE,
contextSize: options.contextSize,
voiceMode: options.voiceMode ?? "execute",
imageOutputMode: "photo",
},
inputAttachments: [],
outputAttachments: [],
artifacts: [],
toolRankDecisions: [],
audit: [],
};
}
export async function prepareUnifiedAiRequestPipeline(params: {
options: UnifiedRunOptions;
config: RuntimeConfigSnapshot;
downloads: AiDownloadedFile[];
streamMessage: TelegramStreamMessage;
controller: AbortController;
}): Promise<PreparedUnifiedAiRequest> {
const {options, config, downloads, streamMessage, controller} = params;
const replyChainDownloads = shouldPreferCurrentDownloads(options.text, downloads)
? downloads
: mergeReplyChainDownloads(
downloads,
attachmentsToDownloadedFiles(await collectStoredReplyChainAttachments(options.msg)),
);
const prepared: MutablePreparedContext = {
chatMessages: [],
imageCount: 0,
firstRoundStatus: Environment.waitThinkText,
toolContext: {},
transcript: "",
finishAfterTranscript: false,
};
const stages: UserRequestPipelineStage[] = [
{
name: "audit_start",
async run() {
return {
stage: "audit_start",
status: "succeeded",
details: {
phase: "ai_request_prepare",
provider: options.provider,
downloads: replyChainDownloads.map(download => ({
kind: download.kind,
fileName: download.fileName,
mimeType: download.mimeType,
sizeBytes: download.sizeBytes ?? download.buffer.length,
})),
},
};
},
},
{
name: "collect_conversation_context",
async run() {
const collected = await collectTextMessages(
options.msg,
options.text,
options.provider,
replyChainDownloads,
config,
runtimeTargetFor(options, config),
options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE,
);
prepared.chatMessages = collected.chatMessages as typeof prepared.chatMessages;
prepared.imageCount = collected.imageCount;
prepared.firstRoundStatus = initialStatus(replyChainDownloads, prepared.imageCount);
prepared.toolContext = toolRuntimeContextFromDownloads(replyChainDownloads);
return {
stage: "collect_conversation_context",
status: "succeeded",
};
},
},
{
name: "prepare_text_context",
async run() {
streamMessage.setStatus(prepared.firstRoundStatus);
await streamMessage.flush();
return {
stage: "prepare_text_context",
status: "succeeded",
};
},
},
{
name: "resolve_runtime",
async run() {
return {
stage: "resolve_runtime",
status: "succeeded",
};
},
},
{
name: "speech_to_text",
async run() {
prepared.transcript = await transcribeAudioIfNeeded(
options.provider,
options.msg.from?.id,
replyChainDownloads,
streamMessage,
controller.signal,
).catch(error => {
if (replyChainDownloads.some(isTranscribableAudioDownload)) throw error;
return "";
});
const transcript = prepared.transcript.trim();
if (!transcript) {
return {
stage: "speech_to_text",
status: "skipped",
};
}
const transcriptArtifact = await persistTranscriptArtifactAttachment({
provider: options.provider,
transcript,
downloads: replyChainDownloads,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
});
if (transcriptArtifact) {
await streamMessage.storeInternalAttachment(transcriptArtifact);
}
if (options.voiceMode === AI_VOICE_MODE_TRANSCRIPT) {
prepared.finishAfterTranscript = true;
streamMessage.replaceText(`[Расшифровка]\n${transcript}`);
await streamMessage.finish();
return {
stage: "speech_to_text",
status: "succeeded",
fallbackAction: "continue_without_stage",
};
}
appendTranscriptToChatMessages(prepared.chatMessages, transcript);
stripAudioFromRunnerMessages(prepared.chatMessages);
aiLog("debug", "request.transcript.appended", {
provider: providerName(options.provider),
transcriptChars: transcript.length,
chatMessages: prepared.chatMessages.length,
});
return {
stage: "speech_to_text",
status: "succeeded",
};
},
},
{
name: "document_rag",
async run() {
if (prepared.finishAfterTranscript) {
return {
stage: "document_rag",
status: "skipped",
};
}
prepared.preparedDocumentRag = await prepareDocumentRag(
options.provider,
replyChainDownloads,
prepared.chatMessages,
streamMessage,
config,
controller.signal,
options.text,
);
const ragArtifact = await persistRagArtifactAttachment({
provider: options.provider,
prepared: prepared.preparedDocumentRag,
downloads: replyChainDownloads,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
details: prepared.preparedDocumentRag?.provider === AiProvider.OPENAI
? {uploadedFileIds: prepared.preparedDocumentRag.uploadedFileIds}
: prepared.preparedDocumentRag?.provider === AiProvider.OLLAMA
? {
embeddingModel: config.ollamaDocumentsTarget.model,
topK: config.ollamaRagTopK,
chunkSize: config.ollamaRagChunkSize,
chunkOverlap: config.ollamaRagChunkOverlap,
maxContextChars: config.ollamaRagMaxContextChars,
artifact: prepared.preparedDocumentRag.artifact,
}
: undefined,
});
if (ragArtifact) {
await streamMessage.storeInternalAttachment(ragArtifact);
}
if (prepared.preparedDocumentRag) {
recordRagRun();
}
return {
stage: "document_rag",
status: prepared.preparedDocumentRag ? "succeeded" : "skipped",
};
},
},
{
name: "audit_finish",
async run() {
return {
stage: "audit_finish",
status: "succeeded",
details: {
phase: "ai_request_prepare",
chatMessages: prepared.chatMessages.length,
imageCount: prepared.imageCount,
hasTranscript: !!prepared.transcript.trim(),
hasDocumentRag: !!prepared.preparedDocumentRag,
finishAfterTranscript: prepared.finishAfterTranscript,
},
};
},
},
];
const state = createAiRequestPipelineState(options);
const fallbackNotifier = new PipelineFallbackNotifier(options.msg, options.responseLanguage);
const pipeline = new UserRequestPipeline({
stages,
stageNames: [
"audit_start",
"collect_conversation_context",
"prepare_text_context",
"resolve_runtime",
"speech_to_text",
"document_rag",
"audit_finish",
],
onFallback: async decision => {
recordPipelineFallback(decision.action);
if (decision.action === "use_alternate_target") {
aiLog("warn", "request.fallback.use_alternate_target", {
provider: options.provider,
stage: decision.stage,
reason: decision.reason,
requestId: state.requestId,
...buildToolRankFallbackTargetDetails(options.provider, config),
});
}
if (decision.action === "fail_request") {
aiLog("error", "request.fallback.fail_request", {
provider: options.provider,
stage: decision.stage,
reason: decision.reason,
requestId: state.requestId,
});
}
const notification = await fallbackNotifier.notify(state.requestId, decision);
state.audit.push({
stage: decision.stage,
status: "fallback",
startedAt: nowIso(),
finishedAt: nowIso(),
details: {
fallbackAction: decision.action,
fallbackNotification: notification.text,
fallbackNotified: notification.notified,
reason: decision.reason,
...(decision.action === "use_alternate_target"
? buildToolRankFallbackTargetDetails(options.provider, config)
: {}),
},
});
},
});
await pipeline.run(state, controller.signal);
await streamMessage.storePipelineAudit(state.audit);
return {
chatMessages: prepared.chatMessages,
imageCount: prepared.imageCount,
firstRoundStatus: prepared.firstRoundStatus,
toolContext: prepared.toolContext,
preparedDocumentRag: prepared.preparedDocumentRag,
finishAfterTranscript: prepared.finishAfterTranscript,
cleanup: async () => {
await prepared.preparedDocumentRag?.cleanup();
},
};
}
-486
View File
@@ -1,486 +0,0 @@
import {AiProvider} from "../model/ai-provider";
import {Environment} from "../common/environment";
import {ifTrue, logError} from "../util/utils";
import {UserRequestPipeline, type UserRequestPipelineState, type UserRequestPipelineStage} from "./user-request-pipeline";
import {getProviderAdapter} from "./provider-adapters";
import type {AiDownloadedFile} from "./telegram-attachments";
import type {TelegramStreamMessage} from "./telegram-stream-message";
import type {PreparedUnifiedAiRequest} from "./unified-ai-request-pipeline";
import type {OpenAIChatMessage} from "./openai-chat-message";
import type {MistralChatMessage} from "./mistral-chat-message";
import type {ChatMessage} from "./chat-messages-types";
import {
allToolSchemaNames,
providerName,
RuntimeConfigSnapshot,
snapshotModel,
TELEGRAM_LIMIT,
UnifiedRunOptions,
} from "./unified-ai-runner.shared";
import {runToolRankStage} from "./tool-rank-stage";
import {runOpenAi} from "./unified-ai-runner.openai";
import {runOpenAiCompatible} from "./unified-ai-runner.openai-compatible";
import {runOllama} from "./unified-ai-runner.ollama";
import {runMistral} from "./unified-ai-runner.mistral";
import {summarizeModelOutput} from "./response-model-output";
import {summarizeToolLoop} from "./tool-loop-summary";
import {persistToolLoopSummaryArtifactAttachment} from "./tool-loop-artifact-store";
import {PipelineFallbackNotifier} from "./user-request-pipeline/fallback-notifier";
import {buildToolRankFallbackTargetDetails} from "./user-request-pipeline/fallback-target-details";
import {
resolveTextToSpeechProviderForUser,
sendSynthesizedSpeech,
speechToOutputAttachmentRecord,
synthesizeSpeech
} from "./text-to-speech";
import {persistFinalTextArtifactAttachment} from "./final-response-artifact-store";
import {aiLog} from "../logging/ai-logger";
import {recordPipelineFallback, recordTtsRun} from "../common/ai-observability.js";
function nowIso(): string {
return new Date().toISOString();
}
function createResponsePipelineState(options: UnifiedRunOptions): UserRequestPipelineState {
return {
requestId: options.requestId ?? `ai-response:${options.msg.chat.id}:${options.msg.message_id}:${Date.now()}`,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
replyToMessageId: options.msg.reply_to_message?.message_id,
fromId: options.msg.from?.id ?? 0,
receivedAt: nowIso(),
text: options.text,
settings: {
provider: options.provider,
responseLanguage: options.responseLanguage ?? "default",
contextSize: options.contextSize,
voiceMode: options.voiceMode ?? "execute",
imageOutputMode: "photo",
},
inputAttachments: [],
outputAttachments: [],
artifacts: [],
toolRankDecisions: [],
audit: [],
};
}
async function runProviderModelCall(params: {
options: UnifiedRunOptions;
config: RuntimeConfigSnapshot;
downloads: AiDownloadedFile[];
prepared: PreparedUnifiedAiRequest;
streamMessage: TelegramStreamMessage;
signal: AbortSignal;
}): Promise<void> {
const {options, config, downloads, prepared, streamMessage, signal} = params;
const preparedDocumentRag = prepared.preparedDocumentRag;
const documents = preparedDocumentRag?.provider === AiProvider.MISTRAL ? preparedDocumentRag.documents : [];
aiLog("info", "request.provider.dispatch", {provider: providerName(options.provider)});
switch (options.provider) {
case AiProvider.OPENAI:
if (config.openAiBackend === "compatible") {
await runOpenAiCompatible(
options.msg,
prepared.chatMessages as OpenAIChatMessage[],
streamMessage,
signal,
options.stream ?? true,
options.msg,
config,
prepared.toolContext,
downloads,
);
return;
}
await runOpenAi(
options.msg,
prepared.chatMessages as OpenAIChatMessage[],
streamMessage,
signal,
options.stream ?? true,
options.msg,
config,
prepared.toolContext,
downloads,
preparedDocumentRag?.provider === AiProvider.OPENAI ? preparedDocumentRag : undefined,
);
return;
case AiProvider.OLLAMA:
if (config.ollamaChatTarget.model?.includes("gpt-oss") && options.think) {
options.think = "high";
}
await runOllama(
options.msg,
prepared.chatMessages as ChatMessage[],
streamMessage,
signal,
ifTrue(options.stream),
options.think ?? false,
prepared.firstRoundStatus,
config,
prepared.toolContext,
options.contextSize,
);
return;
case AiProvider.MISTRAL:
await runMistral(
options.msg,
prepared.chatMessages as MistralChatMessage[],
documents,
streamMessage,
signal,
options.stream ?? true,
prepared.firstRoundStatus,
config,
prepared.toolContext,
);
}
}
async function synthesizeResponseIfRequested(params: {
options: UnifiedRunOptions;
config: RuntimeConfigSnapshot;
streamMessage: TelegramStreamMessage;
}): Promise<"succeeded" | "skipped" | "failed"> {
const {options, config, streamMessage} = params;
if (!options.synthesizeSpeechResponse) return "skipped";
const text = streamMessage.getText().trim();
if (!text) return "skipped";
try {
if (!options.msg.from?.id) {
throw new Error(Environment.couldNotIdentifyUserForSpeechToTextText);
}
const resolved = await resolveTextToSpeechProviderForUser(options.msg.from.id, options.provider)
.catch(() => resolveTextToSpeechProviderForUser(options.msg.from!.id));
const speech = await synthesizeSpeech({provider: resolved.provider, text});
const sent = await sendSynthesizedSpeech(options.msg, speech);
streamMessage.recordOutputAttachment(speechToOutputAttachmentRecord(speech, sent.message_id));
return "succeeded";
} catch (error) {
aiLog("error", "text_to_speech.failed", {
provider: providerName(options.provider),
model: snapshotModel(options.provider, config),
error: error instanceof Error ? error.message : String(error),
});
return "failed";
}
}
export async function runUnifiedAiResponsePipeline(params: {
options: UnifiedRunOptions;
config: RuntimeConfigSnapshot;
downloads: AiDownloadedFile[];
prepared: PreparedUnifiedAiRequest;
streamMessage: TelegramStreamMessage;
controller: AbortController;
}): Promise<void> {
const {options, config, downloads, prepared, streamMessage, controller} = params;
const state = createResponsePipelineState(options);
const fallbackNotifier = new PipelineFallbackNotifier(options.msg, options.responseLanguage);
const adapter = getProviderAdapter(options.provider);
let selectedToolNames: string[] = [];
let filteredTools: unknown[] = [];
const stages: UserRequestPipelineStage[] = [
{
name: "audit_start",
async run() {
return {
stage: "audit_start",
status: "succeeded",
details: {
phase: "ai_response",
provider: options.provider,
model: snapshotModel(options.provider, config),
chatMessages: prepared.chatMessages.length,
hasDocumentRag: !!prepared.preparedDocumentRag,
},
};
},
},
{
name: "tool_rank",
async run() {
const availableTools = adapter.rankTools(config, {
forCreator: options.msg.from?.id === Environment.CREATOR_ID,
vectorStoreIds: prepared.preparedDocumentRag?.provider === AiProvider.OPENAI
? prepared.preparedDocumentRag.vectorStoreIds
: [],
});
const rankResult = await runToolRankStage({
provider: options.provider,
model: snapshotModel(options.provider, config),
round: state.toolRankDecisions.length,
config,
availableTools,
messages: prepared.chatMessages,
streamMessage,
signal: controller.signal,
});
selectedToolNames = rankResult.selectedToolNames;
filteredTools = rankResult.filteredTools;
state.toolRankDecisions.push({
provider: options.provider,
round: state.toolRankDecisions.length,
availableTools: allToolSchemaNames(availableTools),
selectedTools: selectedToolNames,
usedRanker: rankResult.usedRanker,
});
return {
stage: "tool_rank",
status: "succeeded",
details: {
selectedTools: selectedToolNames,
usedRanker: rankResult.usedRanker,
availableTools: allToolSchemaNames(availableTools),
toolRankDecision: state.toolRankDecisions.at(-1),
},
};
},
},
{
name: "filter_tools",
async run() {
return {
stage: "filter_tools",
status: "succeeded",
details: {
selectedTools: selectedToolNames,
filteredToolCount: filteredTools.length,
},
};
},
},
{
name: "model_call",
async run() {
await runProviderModelCall({
options,
config,
downloads,
prepared,
streamMessage,
signal: controller.signal,
});
return {
stage: "model_call",
status: "succeeded",
details: {
modelOutput: summarizeModelOutput({
text: streamMessage.getText(),
toolExecutions: streamMessage.getToolExecutions(),
outputAttachments: streamMessage.getOutputAttachments(),
}),
},
};
},
},
{
name: "tool_loop",
async run() {
const executions = streamMessage.getToolExecutions();
const outputAttachments = streamMessage.getOutputAttachments();
const summary = summarizeToolLoop({
text: streamMessage.getText(),
executions,
outputAttachments,
});
const persisted = await persistToolLoopSummaryArtifactAttachment({
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
text: streamMessage.getText(),
executions,
outputAttachments,
});
if (persisted) {
await streamMessage.storeInternalAttachment(persisted);
}
return {
stage: "tool_loop",
...summary,
details: {
...summary.details,
persistedSummaryArtifact: !!persisted,
},
};
},
},
{
name: "output_size_gate",
async run() {
const originalChars = streamMessage.getText().length;
if (originalChars > TELEGRAM_LIMIT) {
streamMessage.replaceText(streamMessage.getText().slice(0, TELEGRAM_LIMIT - 3) + "...");
}
return {
stage: "output_size_gate",
status: originalChars > TELEGRAM_LIMIT ? "fallback" : "succeeded",
fallbackAction: originalChars > TELEGRAM_LIMIT ? "notify_user" : undefined,
};
},
},
{
name: "send_response",
async run() {
await streamMessage.finish();
return {
stage: "send_response",
status: "succeeded",
};
},
},
{
name: "persist_output_artifacts",
async run() {
const outputAttachments = streamMessage.getOutputAttachments();
const artifact = await persistFinalTextArtifactAttachment({
provider: options.provider,
model: snapshotModel(options.provider, config),
text: streamMessage.getText(),
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
});
if (artifact) {
await streamMessage.storeInternalAttachment(artifact);
}
return {
stage: "persist_output_artifacts",
status: artifact || outputAttachments.length ? "succeeded" : "skipped",
details: {
finalTextPersisted: !!artifact,
outputAttachments,
},
};
},
},
{
name: "text_to_speech",
async run() {
const status = await synthesizeResponseIfRequested({options, config, streamMessage});
recordTtsRun(status);
return {
stage: "text_to_speech",
status,
fallbackAction: status === "failed" ? "continue_without_stage" : undefined,
};
},
},
{
name: "audit_finish",
async run() {
return {
stage: "audit_finish",
status: "succeeded",
details: {
phase: "ai_response",
textChars: streamMessage.getText().length,
toolExecutions: streamMessage.getToolExecutions().length,
outputAttachments: streamMessage.getOutputAttachments().length,
},
};
},
},
];
const responsePipeline = new UserRequestPipeline({
stages,
stageNames: [
"audit_start",
"tool_rank",
"filter_tools",
"model_call",
"tool_loop",
"output_size_gate",
"send_response",
"text_to_speech",
"persist_output_artifacts",
"audit_finish",
],
onFallback: async decision => {
recordPipelineFallback(decision.action);
if (decision.action === "use_alternate_target") {
aiLog("warn", "response.fallback.use_alternate_target", {
provider: options.provider,
stage: decision.stage,
reason: decision.reason,
requestId: state.requestId,
...buildToolRankFallbackTargetDetails(options.provider, config),
});
}
if (decision.action === "fail_request") {
aiLog("error", "response.fallback.fail_request", {
provider: options.provider,
stage: decision.stage,
reason: decision.reason,
requestId: state.requestId,
});
}
const notification = await fallbackNotifier.notify(state.requestId, decision);
state.audit.push({
stage: decision.stage,
status: "fallback",
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
details: {
fallbackAction: decision.action,
fallbackNotification: notification.text,
fallbackNotified: notification.notified,
reason: decision.reason,
...(decision.action === "use_alternate_target"
? buildToolRankFallbackTargetDetails(options.provider, config)
: {}),
},
});
},
});
try {
await responsePipeline.run(state, controller.signal);
await streamMessage.storePipelineAudit(state.audit);
} catch (error) {
await streamMessage.storePipelineAudit(state.audit).catch(logError);
throw error;
} finally {
const cleanupState = createResponsePipelineState(options);
const cleanupPipeline = new UserRequestPipeline({
stages: [{
name: "cleanup",
async run() {
await prepared.cleanup();
return {
stage: "cleanup",
status: "succeeded",
};
},
}],
stageNames: ["cleanup"],
});
try {
await cleanupPipeline.run(cleanupState, controller.signal);
await streamMessage.storePipelineAudit(cleanupState.audit);
} catch (error) {
await streamMessage.storePipelineAudit(cleanupState.audit).catch(logError);
logError(error instanceof Error ? error : String(error));
}
}
}
+176
View File
@@ -0,0 +1,176 @@
// Gemini provider runner extracted from unified-ai-runner.ts.
import {getGeminiTools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime";
import {GeminiMessage} from "./gemini-chat-message";
import {createGoogleGenAiClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {
AsyncIterableStream,
executeToolBatch,
GeminiFunctionCallLike,
GeminiGenerationRequest,
GeminiResponseLike,
MAX_TOOL_ROUNDS,
roundStatus,
RuntimeConfigSnapshot,
safeJsonParseObject,
ToolCallData,
ToolExecutionMemory
} from "./unified-ai-runner.shared";
function collectGeminiResponseText(response: GeminiResponseLike & { text?: string }): string {
if (typeof response.text === "string") return response.text;
return (response.candidates ?? [])
.flatMap(candidate => candidate.content?.parts ?? [])
.map(part => part.text ?? "")
.join("");
}
function collectGeminiFunctionCalls(response: GeminiResponseLike): ToolCallData[] {
const calls = response.functionCalls
?? (response.candidates ?? []).flatMap(candidate => {
return (candidate.content?.parts ?? [])
.map(part => part.functionCall)
.filter((call): call is GeminiFunctionCallLike => !!call);
});
return (calls ?? []).map((call, index) => ({
id: call.id ?? `gemini_${index}_${call.name ?? "call"}`,
name: call.name ?? "",
argumentsText: JSON.stringify(call.args ?? {}),
})).filter((call: ToolCallData) => call.name);
}
function mergeGeminiFunctionCalls(existing: ToolCallData[], next: ToolCallData[]): ToolCallData[] {
const merged = [...existing];
for (const call of next) {
const index = merged.findIndex(item => item.id === call.id);
if (index === -1) {
merged.push(call);
} else {
merged[index] = call;
}
}
return merged;
}
function appendGeminiToolRound(messages: GeminiMessage[], calls: ToolCallData[], results: string[]): void {
messages.push({
role: "model",
parts: calls.map(call => ({
functionCall: {
id: call.id,
name: call.name,
args: safeJsonParseObject(call.argumentsText),
},
})),
});
messages.push({
role: "user",
parts: calls.map((call, index) => ({
functionResponse: {
id: call.id,
name: call.name,
response: {result: results[index] ?? ""},
},
})),
});
}
export async function runGemini(
messages: GeminiMessage[],
streamMessage: TelegramStreamMessage,
signal: AbortSignal,
stream: boolean,
firstRoundStatus: string,
config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext,
): Promise<void> {
const runnerStartedAt = Date.now();
const geminiAi = createGoogleGenAiClient(config.geminiChatTarget);
aiLog("info", "gemini.run.start", {
stream,
target: aiLogProviderTarget(config.geminiChatTarget),
inputMessages: messages.length,
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
});
// TODO: 13.05.2026, Danil Nikolaev: find a better way?
const imageCount = messages.reduce((sum, m) => {
return sum + (m.parts.filter(p => "inlineData" in p && "mimeType" in p.inlineData && p.inlineData.mimeType.startsWith("image")).length)
}, 0);
const target = imageCount ? config.geminiImageTarget : config.geminiChatTarget;
const model = target.model;
const toolMemory: ToolExecutionMemory = new Map();
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const roundStartedAt = Date.now();
aiLog("debug", "gemini.round.start", {round, messages: messages.length, stream});
if (signal.aborted) throw new Error("Aborted");
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
await streamMessage.flush();
const request: GeminiGenerationRequest = {
model: model,
contents: messages,
config: {
tools: getGeminiTools(),
temperature: messages.length <= 2 ? 0 : 0.6,
abortSignal: signal,
},
};
if (!stream) {
const response = await geminiAi.models.generateContent(request) as unknown as GeminiResponseLike & {
text?: string
};
const text = collectGeminiResponseText(response);
streamMessage.append(text);
const calls = collectGeminiFunctionCalls(response);
aiLog(calls.length ? "info" : "success", calls.length ? "gemini.tool_calls" : "gemini.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: text.length,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
appendGeminiToolRound(messages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory));
continue;
}
const response = await geminiAi.models.generateContentStream(request) as unknown as AsyncIterableStream<GeminiResponseLike & {
text?: string
}>;
aiLog("debug", "gemini.stream.open", {round});
let calls: ToolCallData[] = [];
const roundTextStart = streamMessage.getText().length;
for await (const chunk of response) {
if (signal.aborted) throw new Error("Aborted");
streamMessage.append(collectGeminiResponseText(chunk));
calls = mergeGeminiFunctionCalls(calls, collectGeminiFunctionCalls(chunk));
}
aiLog(calls.length ? "info" : "success", calls.length ? "gemini.tool_calls" : "gemini.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: streamMessage.getText().slice(roundTextStart).length,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
appendGeminiToolRound(messages, calls, await executeToolBatch(calls, streamMessage, toolContext, toolMemory));
}
}
export class GeminiProviderRunner {
static run = runGemini;
}
+42 -123
View File
@@ -1,32 +1,15 @@
// Mistral provider runner extracted from unified-ai-runner.ts.
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {getMistralTools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message"; import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime"; import {ToolRuntimeContext} from "./tools/runtime";
import {MistralChatMessage} from "./mistral-chat-message"; import {MistralChatMessage} from "./mistral-chat-message";
import {createMistralClient} from "./ai-runtime-target"; import {createMistralClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger"; import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {AiProvider} from "../model/ai-provider";
import {getProviderAdapter} from "./provider-adapters";
import {runToolRankStage} from "./tool-rank-stage";
import {ensureToolsSelected} from "./tool-mappers.js";
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
import { import {MAX_TOOL_ROUNDS, MistralDeltaLike, MistralDocumentReference, RuntimeConfigSnapshot, StreamingToolCallAccumulator, ToolCallData, ToolExecutionMemory, contentFromMistralDelta, executeToolBatch, mistralToolCalls, normalizeMistralToolCalls, roundStatus} from "./unified-ai-runner.shared";
MAX_TOOL_ROUNDS,
MistralDocumentReference,
roundStatus,
RuntimeConfigSnapshot,
StreamingToolCallAccumulator,
ToolCallData,
ToolExecutionMemory
} from "./unified-ai-runner.shared";
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
import {decideToolLoopContinuation} from "./tool-loop-control";
import {runToolLoopRounds} from "./tool-loop-runner";
import {runSingleModelRequest} from "./model-call-stage";
import {Message} from "typescript-telegram-bot-api";
export async function runMistral( export async function runMistral(
msg: Message,
messages: MistralChatMessage[], messages: MistralChatMessage[],
documents: MistralDocumentReference[], documents: MistralDocumentReference[],
streamMessage: TelegramStreamMessage, streamMessage: TelegramStreamMessage,
@@ -38,9 +21,6 @@ export async function runMistral(
): Promise<void> { ): Promise<void> {
const runnerStartedAt = Date.now(); const runnerStartedAt = Date.now();
const mistralAi = createMistralClient(config.mistralChatTarget); const mistralAi = createMistralClient(config.mistralChatTarget);
const adapter = getProviderAdapter(AiProvider.MISTRAL);
const availableTools = adapter.rankTools(config, {forCreator: msg.from?.id === Environment.CREATOR_ID});
const requestMessages = adapter.mapMessages([...messages]) as unknown as MistralChatMessage[];
aiLog("info", "mistral.run.start", { aiLog("info", "mistral.run.start", {
stream, stream,
target: aiLogProviderTarget(config.mistralChatTarget), target: aiLogProviderTarget(config.mistralChatTarget),
@@ -50,51 +30,34 @@ export async function runMistral(
}); });
const toolMemory: ToolExecutionMemory = new Map(); const toolMemory: ToolExecutionMemory = new Map();
try {
await runToolLoopRounds({ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
maxRounds: MAX_TOOL_ROUNDS,
onRound: async (round) => {
const roundStartedAt = Date.now(); const roundStartedAt = Date.now();
aiLog("debug", "mistral.round.start", {round, messages: messages.length, stream}); aiLog("debug", "mistral.round.start", {round, messages: messages.length, stream});
if (signal.aborted) throw new Error("Aborted"); if (signal.aborted) throw new Error("Aborted");
const rankResult = await runToolRankStage({
provider: AiProvider.MISTRAL,
model: config.mistralChatTarget.model,
round,
config,
availableTools,
messages,
streamMessage,
signal,
});
const filteredTools = ensureToolsSelected(availableTools, rankResult.filteredTools, MEMORY_TOOL_NAMES);
const requestTools = filteredTools.length ? filteredTools : undefined;
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? ""); streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
await streamMessage.flush(); await streamMessage.flush();
if (!stream) { if (!stream) {
const request = { const request = {
model: config.mistralChatTarget.model, model: config.mistralChatTarget.model,
messages: requestMessages, messages,
tools: requestTools, tools: getMistralTools(),
documents: documents documents: documents
} as Parameters<typeof mistralAi.chat.complete>[0]; } as unknown as Parameters<typeof mistralAi.chat.complete>[0];
const response = await runSingleModelRequest({ const response = await mistralAi.chat.complete(request, {signal});
execute: () => adapter.callModel(request, () => mistralAi.chat.complete(request, {signal})), const msg = response.choices?.[0]?.message;
}); const text = typeof msg?.content === "string" ? msg.content : JSON.stringify(msg?.content ?? "");
const message = response.choices?.[0]?.message;
const text = typeof message?.content === "string" ? message.content : JSON.stringify(message?.content ?? "");
streamMessage.append(text); streamMessage.append(text);
const calls = adapter.extractToolCalls(message); const calls = normalizeMistralToolCalls(mistralToolCalls(msg));
aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", { aiLog(calls.length ? "info" : "success", calls.length ? "mistral.tool_calls" : "mistral.run.done", {
round, round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt), duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: text.length, textChars: text.length,
calls: calls.map(aiLogToolCall), calls: calls.map(aiLogToolCall),
}); });
if (!calls.length) return {shouldContinue: false}; if (!calls.length) return;
messages.push({ messages.push({
role: "assistant", role: "assistant",
content: text, content: text,
@@ -103,50 +66,25 @@ export async function runMistral(
function: {name: call.name, arguments: call.argumentsText}, function: {name: call.name, arguments: call.argumentsText},
})), })),
}); });
requestMessages.push({ const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
role: "assistant", for (const [index, call] of calls.entries()) {
content: text, messages.push({
toolCalls: calls.map(call => ({ role: "tool",
id: call.id, name: call.name,
function: {name: call.name, arguments: call.argumentsText}, toolCallId: call.id,
})), content: toolResults[index] ?? "",
});
await executeToolBatchWithAdapter({
userId: msg.from?.id,
toolCalls: calls,
streamMessage,
toolContext: {
...toolContext,
provider: AiProvider.MISTRAL,
runtimeTarget: config.mistralChatTarget,
},
toolMemory,
adapter,
appendTargets: [messages, requestMessages],
});
const continuation = decideToolLoopContinuation({
round,
maxRounds: MAX_TOOL_ROUNDS,
toolCalls: calls,
});
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "mistral.tool_loop.max_rounds_reached", {
round,
maxRounds: MAX_TOOL_ROUNDS,
}); });
} }
return {shouldContinue: true}; continue;
} }
const request = { const request = {
model: config.mistralChatTarget.model, model: config.mistralChatTarget.model,
messages: requestMessages, messages,
tools: requestTools, tools: getMistralTools(),
documents: documents documents: documents
} as Parameters<typeof mistralAi.chat.stream>[0]; } as unknown as Parameters<typeof mistralAi.chat.stream>[0];
const streamResponse = await runSingleModelRequest({ const streamResponse = await mistralAi.chat.stream(request, {signal});
execute: () => adapter.callModel(request, () => mistralAi.chat.stream(request, {signal})),
});
aiLog("debug", "mistral.stream.open", {round}); aiLog("debug", "mistral.stream.open", {round});
let calls: ToolCallData[] = []; let calls: ToolCallData[] = [];
const roundTextStart = streamMessage.getText().length; const roundTextStart = streamMessage.getText().length;
@@ -157,10 +95,11 @@ export async function runMistral(
const choice = event.data?.choices?.[0]; const choice = event.data?.choices?.[0];
const delta = choice?.delta; const delta = choice?.delta;
const mistralDelta = delta; const mistralDelta = delta as MistralDeltaLike;
streamMessage.append(adapter.extractTextDelta(mistralDelta));
const rawDeltaCalls = adapter.extractStreamingToolCalls(mistralDelta); streamMessage.append(contentFromMistralDelta(mistralDelta));
const rawDeltaCalls = mistralToolCalls(mistralDelta);
if (rawDeltaCalls.length) { if (rawDeltaCalls.length) {
calls = toolCallAccumulator.add(rawDeltaCalls); calls = toolCallAccumulator.add(rawDeltaCalls);
streamMessage.setStatus(Environment.getUseToolText(calls)); streamMessage.setStatus(Environment.getUseToolText(calls));
@@ -173,46 +112,26 @@ export async function runMistral(
textChars: streamMessage.getText().slice(roundTextStart).length, textChars: streamMessage.getText().slice(roundTextStart).length,
calls: calls.map(aiLogToolCall), calls: calls.map(aiLogToolCall),
}); });
if (!calls.length) return {shouldContinue: false}; if (!calls.length) return;
const roundText = streamMessage.getText().slice(roundTextStart); const roundText = streamMessage.getText().slice(roundTextStart);
messages.push({ messages.push({
role: "assistant", role: "assistant",
content: roundText, content: roundText,
toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}})) toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}}))
}); });
requestMessages.push({ const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
role: "assistant", for (const [index, call] of calls.entries()) {
content: roundText, messages.push({
toolCalls: calls.map(c => ({id: c.id, function: {name: c.name, arguments: c.argumentsText}})) role: "tool",
}); name: call.name,
await executeToolBatchWithAdapter({ toolCallId: call.id,
userId: msg.from?.id, content: toolResults[index] ?? "",
toolCalls: calls,
streamMessage,
toolContext: {
...toolContext,
provider: AiProvider.MISTRAL,
runtimeTarget: config.mistralChatTarget,
},
toolMemory,
adapter,
appendTargets: [messages, requestMessages],
});
const continuation = decideToolLoopContinuation({
round,
maxRounds: MAX_TOOL_ROUNDS,
toolCalls: calls,
});
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "mistral.tool_loop.max_rounds_reached", {
round,
maxRounds: MAX_TOOL_ROUNDS,
}); });
} }
return {shouldContinue: true};
},
});
} finally {
await adapter.finalize().catch(() => undefined);
} }
} }
export class MistralProviderRunner {
static run = runMistral;
}
+79 -172
View File
@@ -1,48 +1,28 @@
// Ollama provider runner extracted from unified-ai-runner.ts. // Ollama provider runner extracted from unified-ai-runner.ts.
import {Message} from "typescript-telegram-bot-api";
import * as fs from "node:fs"; import * as fs from "node:fs";
import path from "node:path"; import path from "node:path";
import {AiProvider} from "../model/ai-provider";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import type {BoundaryValue} from "../common/boundary-types";
import {bot, notesDir} from "../index"; import {bot, notesDir} from "../index";
import {clamp, logError} from "../util/utils"; import {clamp, logError} from "../util/utils";
import {getOllamaTools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message"; import {TelegramStreamMessage} from "./telegram-stream-message";
import {getModelCapabilities} from "./provider-model-runtime";
import {ChatMessage} from "./chat-messages-types"; import {ChatMessage} from "./chat-messages-types";
import {ChatRequest, Tool} from "ollama"; import {ChatRequest, Tool} from "ollama";
import {ToolRuntimeContext} from "./tools/runtime"; import {ToolRuntimeContext} from "./tools/runtime";
import {enqueueTelegramApiCall} from "../util/telegram-api-queue"; import {enqueueTelegramApiCall} from "../util/telegram-api-queue";
import {getCurrentDateTimeTool} from "./tools/datetime";
import {getMarketRatesTool} from "./tools/market-rates";
import {getWeatherTool} from "./tools/weather";
import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils"; import {loadOllamaModel, unloadAllOllamaModels} from "./tools/utils";
import {createOllamaClient} from "./ai-runtime-target"; import {createOllamaClient} from "./ai-runtime-target";
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-file";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger"; import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {getProviderAdapter} from "./provider-adapters";
import {runToolRankStage} from "./tool-rank-stage";
import {ensureToolsSelected} from "./tool-mappers.js";
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
import { import {DEFAULT_OLLAMA_CONTEXT_SIZE, MAX_OLLAMA_CONTEXT_SIZE, MAX_TOOL_ROUNDS, MIN_OLLAMA_CONTEXT_SIZE, RuntimeConfigSnapshot, Think, ToolCallData, ToolExecutionMemory, allToolSchemaNames, appendOllamaToolResults, dedupeToolCalls, executeToolBatch, normalizeOllamaToolCalls, roundStatus, safeJsonParseObject, isRecord, isOllamaModelActive, OllamaToolCallLike} from "./unified-ai-runner.shared";
allToolSchemaNames, import {latestUserTextFromOllamaMessages, looksLikeToolRankerJson, OllamaToolRanker} from "./unified-ai-runner.tool-ranker";
dedupeToolCalls,
DEFAULT_OLLAMA_CONTEXT_SIZE,
isOllamaModelActive,
isRecord,
MAX_OLLAMA_CONTEXT_SIZE,
MAX_TOOL_ROUNDS,
MIN_OLLAMA_CONTEXT_SIZE,
roundStatus,
RuntimeConfigSnapshot,
safeJsonParseObject,
Think,
ToolCallData,
ToolExecutionMemory
} from "./unified-ai-runner.shared";
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
import {decideToolLoopContinuation} from "./tool-loop-control";
import {runToolLoopRounds} from "./tool-loop-runner";
import {runSingleModelRequest} from "./model-call-stage";
import {getToolPrompts} from "./tools/registry";
import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/notes";
import {getModelCapabilities} from "./provider-model-runtime";
import {AiProvider} from "../model/ai-provider";
import {Message} from "typescript-telegram-bot-api";
export async function runOllama( export async function runOllama(
msg: Message, msg: Message,
@@ -56,6 +36,7 @@ export async function runOllama(
toolContext: ToolRuntimeContext, toolContext: ToolRuntimeContext,
contextSize?: number, contextSize?: number,
): Promise<void> { ): Promise<void> {
const fromId = msg.from?.id;
const runnerStartedAt = Date.now(); const runnerStartedAt = Date.now();
const audioCount = messages.reduce((sum, m) => sum + (m.audioParts?.length || m.audios?.length || 0), 0); const audioCount = messages.reduce((sum, m) => sum + (m.audioParts?.length || m.audios?.length || 0), 0);
@@ -78,8 +59,9 @@ export async function runOllama(
const ollama = createOllamaClient(target); const ollama = createOllamaClient(target);
const modelInfo = await ollama.show({model}); const modelInfo = await ollama.show({model});
const modelInfoMap: Record<string, BoundaryValue> = isRecord(modelInfo.model_info) ? modelInfo.model_info : {}; const modelInfoMap = isRecord(modelInfo.model_info) ? modelInfo.model_info : {};
const contextKey = Object.keys(modelInfoMap).find(k => k.endsWith(".context_length")); const contextKey = Object.keys(modelInfoMap).find(k => k.endsWith(".context_length"));
// @ts-ignore
const rawMaxContextLength = contextKey ? modelInfoMap[contextKey] : undefined; const rawMaxContextLength = contextKey ? modelInfoMap[contextKey] : undefined;
const parsedMaxContextLength = const parsedMaxContextLength =
typeof rawMaxContextLength === "number" typeof rawMaxContextLength === "number"
@@ -115,7 +97,7 @@ export async function runOllama(
await unloadAllOllamaModels(ollama, modelsToLoad); await unloadAllOllamaModels(ollama, modelsToLoad);
} }
} catch (e) { } catch (e) {
logError(e instanceof Error ? e : String(e)); logError(e);
} }
if (!(await isOllamaModelActive(ollama, target))) { if (!(await isOllamaModelActive(ollama, target))) {
@@ -157,12 +139,9 @@ export async function runOllama(
} }
const toolMemory: ToolExecutionMemory = new Map(); const toolMemory: ToolExecutionMemory = new Map();
const adapter = getProviderAdapter(AiProvider.OLLAMA);
try { try {
await runToolLoopRounds({ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
maxRounds: MAX_TOOL_ROUNDS,
onRound: async (round) => {
const roundStartedAt = Date.now(); const roundStartedAt = Date.now();
aiLog("debug", "ollama.round.start", { aiLog("debug", "ollama.round.start", {
round, round,
@@ -177,16 +156,16 @@ export async function runOllama(
messages: messages, messages: messages,
think: audioCount ? false : think, think: audioCount ? false : think,
options: { options: {
temperature: 0.7, temperature: 0.6,
top_p: 0.9, num_ctx: context,
top_k: 40,
num_ctx: 16384
} }
}; };
let activeToolNames: string[] = []; let activeToolNames: string[] = [];
if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) { if ((await getModelCapabilities(AiProvider.OLLAMA, model, "tools"))?.tools?.supported) {
const availableOllamaTools: Tool[] = adapter.rankTools(config, {forCreator: msg.from?.id === Environment.CREATOR_ID}) as Tool[]; const availableOllamaTools: Tool[] = fromId !== Environment.CREATOR_ID
? [getCurrentDateTimeTool, getMarketRatesTool, getWeatherTool]
: getOllamaTools() as Tool[];
aiLog("debug", "ollama.tools.available", { aiLog("debug", "ollama.tools.available", {
round, round,
@@ -194,37 +173,16 @@ export async function runOllama(
rankerEnabled: !!config.ollamaToolRankerTarget, rankerEnabled: !!config.ollamaToolRankerTarget,
}); });
const rankResult = await runToolRankStage({ const rankerSelection = await new OllamaToolRanker(config).selectTools({
provider: AiProvider.OLLAMA, userQuery: latestUserTextFromOllamaMessages(messages),
model,
round,
config,
availableTools: availableOllamaTools, availableTools: availableOllamaTools,
messages, round,
streamMessage,
signal, signal,
}); });
const filteredTools = [...new Set(ensureToolsSelected(availableOllamaTools, rankResult.filteredTools as Tool[], MEMORY_TOOL_NAMES) as Tool[])]; activeToolNames = rankerSelection.selectedNames;
activeToolNames = filteredTools.map(t => t.function.name ?? ""); if (rankerSelection.tools.length > 0) {
if (filteredTools.length > 0) { request.tools = rankerSelection.tools;
request.tools = [...filteredTools];
request.options = {
...request.options,
temperature: 0
};
const newMessage = messages[messages.length - 1];
if (newMessage) {
newMessage.content += "\n" + "Suggested tools to call: " + activeToolNames.join(", ");
}
const systemMessage = messages.find(m => m.role === "system");
if (systemMessage) {
systemMessage.content += "\n\n" + getToolPrompts(activeToolNames).join("\n\n");
}
request.model = config.ollamaToolTarget.model;
} else { } else {
delete request.tools; delete request.tools;
} }
@@ -233,35 +191,37 @@ export async function runOllama(
round, round,
tools: activeToolNames, tools: activeToolNames,
count: activeToolNames.length, count: activeToolNames.length,
usedRanker: rankResult.usedRanker, usedRanker: rankerSelection.usedRanker,
missing: rankerSelection.missing,
}); });
} }
if (!stream) { if (!stream) {
const response = await runSingleModelRequest({ const response = await ollama.chat({
execute: () => adapter.callModel(request, () => ollama.chat({
...request, ...request,
stream: false stream: false
})),
}); });
const message = response.message; const message = response.message;
const rawContent = message?.content ?? ""; const rawContent = message?.content ?? "";
const nativeCalls = dedupeToolCalls( const nativeCalls = dedupeToolCalls(
adapter.extractToolCalls(message), normalizeOllamaToolCalls(
message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
round,
),
); );
const responseText = rawContent; const responseText = rawContent;
// if (looksLikeToolRankerJson(responseText)) { if (looksLikeToolRankerJson(responseText)) {
// aiLog("error", "ollama.response.looks_like_tool_ranker_json", { aiLog("error", "ollama.response.looks_like_tool_ranker_json", {
// round, round,
// preview: responseText.slice(0, 800), preview: responseText.slice(0, 800),
// target: aiLogProviderTarget(target), target: aiLogProviderTarget(target),
// }); });
// throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up."); throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up.");
// } }
streamMessage.append(responseText); streamMessage.append(responseText);
@@ -274,10 +234,10 @@ export async function runOllama(
if (!nativeCalls.length) { if (!nativeCalls.length) {
aiLog("success", "ollama.run.done", {round, duration: aiLogDuration(runnerStartedAt)}); aiLog("success", "ollama.run.done", {round, duration: aiLogDuration(runnerStartedAt)});
return {shouldContinue: false}; break;
} }
const calls = adapter.extractToolCalls(message).length ? adapter.extractToolCalls(message) : nativeCalls; const calls = nativeCalls;
aiLog("info", "ollama.tool_calls", { aiLog("info", "ollama.tool_calls", {
round, round,
@@ -295,44 +255,18 @@ export async function runOllama(
})), })),
}); });
await executeToolBatchWithAdapter({ appendOllamaToolResults(
userId: msg.from?.id, messages,
toolCalls: calls, calls,
streamMessage, await executeToolBatch(calls, streamMessage, toolContext, toolMemory),
toolContext: { );
...toolContext,
provider: AiProvider.OLLAMA,
runtimeTarget: target,
},
toolMemory,
adapter,
appendTargets: [messages],
});
const continuation = decideToolLoopContinuation({ continue;
round,
maxRounds: MAX_TOOL_ROUNDS,
toolCalls: calls,
});
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "ollama.tool_loop.max_rounds_reached", {
round,
maxRounds: MAX_TOOL_ROUNDS,
});
} }
return {shouldContinue: true}; const response = await ollama.chat({
}
aiLog("debug", "ollama.stream.messages", {
round,
messageCount: request.messages?.length ?? 0,
});
const response = await runSingleModelRequest({
execute: () => adapter.callModel(request, () => ollama.chat({
...request, ...request,
stream: true stream: true
})),
}); });
aiLog("debug", "ollama.stream.open", {round}); aiLog("debug", "ollama.stream.open", {round});
@@ -343,16 +277,12 @@ export async function runOllama(
if (signal.aborted) abortOllamaResponse(); if (signal.aborted) abortOllamaResponse();
try { try {
for await (const chunk of response) { for await (const chunk of response) {
aiLog("trace", "ollama.stream.chunk", {
round,
contentPreview: chunk.message.content?.slice(0, 240),
hasToolCalls: !!chunk.message.tool_calls?.length,
hasThinking: !!chunk.message.thinking,
});
const localToolCalls: ToolCallData[] = []; const localToolCalls: ToolCallData[] = [];
localToolCalls.push(...adapter.extractStreamingToolCalls(chunk.message)); localToolCalls.push(...normalizeOllamaToolCalls(
chunk.message.tool_calls as readonly OllamaToolCallLike[] | undefined,
round,
));
const newStatus = roundStatus(round, firstRoundStatus, chunk.message.content, localToolCalls, !!chunk.message.thinking); const newStatus = roundStatus(round, firstRoundStatus, chunk.message.content, localToolCalls, !!chunk.message.thinking);
const previousStatus = streamMessage.getStatus(); const previousStatus = streamMessage.getStatus();
@@ -372,10 +302,13 @@ export async function runOllama(
} }
if (!(chunk.message?.thinking && streamMessage.getStatus() !== Environment.reasoningText)) { if (!(chunk.message?.thinking && streamMessage.getStatus() !== Environment.reasoningText)) {
streamMessage.append(adapter.extractTextDelta(chunk)); streamMessage.append(chunk.message?.content ?? "");
} }
calls.push(...adapter.extractStreamingToolCalls(chunk.message)); calls.push(...normalizeOllamaToolCalls(
chunk.message?.tool_calls as readonly OllamaToolCallLike[] | undefined,
round,
));
if (chunk.done) { if (chunk.done) {
aiLog("debug", "ollama.stream.done", { aiLog("debug", "ollama.stream.done", {
@@ -391,16 +324,16 @@ export async function runOllama(
signal.removeEventListener("abort", abortOllamaResponse); signal.removeEventListener("abort", abortOllamaResponse);
} }
// const streamedRoundText = streamMessage.getText().slice(roundTextStart); const streamedRoundText = streamMessage.getText().slice(roundTextStart);
// if (!calls.length && looksLikeToolRankerJson(streamedRoundText)) { if (!calls.length && looksLikeToolRankerJson(streamedRoundText)) {
// streamMessage.replaceText(streamMessage.getText().slice(0, roundTextStart)); streamMessage.replaceText(streamMessage.getText().slice(0, roundTextStart));
// aiLog("error", "ollama.response.looks_like_tool_ranker_json", { aiLog("error", "ollama.response.looks_like_tool_ranker_json", {
// round, round,
// preview: streamedRoundText.slice(0, 800), preview: streamedRoundText.slice(0, 800),
// target: aiLogProviderTarget(target), target: aiLogProviderTarget(target),
// }); });
// throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up."); throw new Error("Ollama chat model returned tool-ranker JSON. Check that OLLAMA chat target and OLLAMA tools/ranker target are not mixed up.");
// } }
if (!calls.length) { if (!calls.length) {
aiLog("success", "ollama.run.done", { aiLog("success", "ollama.run.done", {
@@ -408,7 +341,7 @@ export async function runOllama(
duration: aiLogDuration(runnerStartedAt), duration: aiLogDuration(runnerStartedAt),
}); });
return {shouldContinue: false}; break;
} }
calls.splice(0, calls.length, ...dedupeToolCalls(calls)); calls.splice(0, calls.length, ...dedupeToolCalls(calls));
@@ -431,31 +364,7 @@ export async function runOllama(
})), })),
}); });
const toolResults = await executeToolBatchWithAdapter({ const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
userId: msg.from?.id,
toolCalls: calls,
streamMessage,
toolContext: {
...toolContext,
provider: AiProvider.OLLAMA,
runtimeTarget: target,
},
toolMemory,
adapter,
appendTargets: [messages],
});
const continuation = decideToolLoopContinuation({
round,
maxRounds: MAX_TOOL_ROUNDS,
toolCalls: calls,
});
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "ollama.tool_loop.max_rounds_reached", {
round,
maxRounds: MAX_TOOL_ROUNDS,
});
}
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined; let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
@@ -473,25 +382,23 @@ export async function runOllama(
} }
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) { if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
const attachmentPath = path.join(notesDir, successGetNoteFileResult.attachment.relativePath);
if (!fs.existsSync(attachmentPath)) {
throw new Error(`Attachment file does not exist: ${attachmentPath}`);
}
await bot.sendDocument({ await bot.sendDocument({
chat_id: msg.chat.id, chat_id: msg.chat.id,
reply_parameters: { reply_parameters: {
message_id: msg.message_id, message_id: msg.message_id,
}, },
document: fs.createReadStream(attachmentPath), document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
}).catch(logError); }).catch(logError);
} }
return {shouldContinue: true}; appendOllamaToolResults(messages, calls, toolResults);
}, }
});
} finally { } finally {
if (interval) clearInterval(interval); if (interval) clearInterval(interval);
await adapter.finalize().catch(() => undefined);
} }
} }
export class OllamaProviderRunner {
static run = runOllama;
}
@@ -1,419 +0,0 @@
import {Message} from "typescript-telegram-bot-api";
import type {
ChatCompletionCreateParamsNonStreaming,
ChatCompletionCreateParamsStreaming,
ChatCompletionTool,
} from "openai/resources/chat/completions";
import {Environment} from "../common/environment.js";
import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime";
import {OpenAIChatMessage, OpenAICompatibleChatMessage} from "./openai-chat-message";
import {createOpenAiClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import type {BoundaryValue} from "../common/boundary-types.js";
import {
AsyncIterableStream,
buildSystemInstruction,
MAX_TOOL_ROUNDS,
OpenAiChatCompletionResponseLike,
OpenAiChatCompletionStreamChunkLike,
RuntimeConfigSnapshot,
safeJsonParseObject,
ToolCallData,
ToolExecutionMemory,
} from "./unified-ai-runner.shared";
import {mergeToolCallChunks, normalizeStreamingTextDelta} from "./provider-adapter-contract.js";
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
import {decideToolLoopContinuation} from "./tool-loop-control";
import {runToolLoopRounds} from "./tool-loop-runner";
import {runSingleModelRequest} from "./model-call-stage";
import {ensureToolsSelected, getOpenAICompatibleTools} from "./tool-mappers.js";
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
import {logError} from "../util/utils";
import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
import {AiDownloadedFile} from "./telegram-attachments";
import {AiProvider} from "../model/ai-provider";
import {getProviderAdapter} from "./provider-adapters";
import {runToolRankStage} from "./tool-rank-stage";
import type {AiProviderAdapter} from "./provider-adapters.js";
import {tryToUploadFiles} from "./openai-upload-files.js";
import {buildAssistantToolMessage, openAiResponseMessagesToChatCompletions} from "./openai-chat-completions.js";
function describeOpenAiCompatibleError(error: unknown): Record<string, unknown> {
const err = error as {
message?: unknown;
status?: unknown;
code?: unknown;
type?: unknown;
error?: unknown;
} | undefined;
return {
errorSummary: typeof err?.message === "string" ? err.message : String(error),
httpStatus: err?.status,
errorCode: err?.code,
errorType: err?.type,
};
}
async function executeChatCompletionWithOptionalToolFallback<T>(params: {
openAi: ReturnType<typeof createOpenAiClient>;
request: ChatCompletionCreateParamsNonStreaming | ChatCompletionCreateParamsStreaming;
signal: AbortSignal;
stream: boolean;
}): Promise<T> {
try {
return await params.openAi.chat.completions.create(params.request as never, {signal: params.signal}) as T;
} catch (error) {
const requestWithTools = params.request as {tools?: unknown[]};
if (!requestWithTools.tools || !Array.isArray(requestWithTools.tools) || requestWithTools.tools.length === 0) {
aiLog("error", "openai_compatible.request.failed", {
stream: params.stream,
hasTools: false,
error: describeOpenAiCompatibleError(error),
});
throw error;
}
aiLog("warn", "openai_compatible.tools.retry_without_tools", {
stream: params.stream,
error: describeOpenAiCompatibleError(error),
});
const retryRequest = {...params.request} as ChatCompletionCreateParamsNonStreaming | ChatCompletionCreateParamsStreaming & {tools?: unknown[]};
delete retryRequest.tools;
try {
return await params.openAi.chat.completions.create(retryRequest as never, {signal: params.signal}) as T;
} catch (retryError) {
aiLog("error", "openai_compatible.request.retry_without_tools.failed", {
stream: params.stream,
hasTools: true,
error: describeOpenAiCompatibleError(retryError),
});
throw retryError;
}
}
}
function makeChatCompletionAdapter(): AiProviderAdapter {
const baseAdapter = getProviderAdapter(AiProvider.OPENAI);
return {
...baseAdapter,
callModel: baseAdapter.callModel.bind(baseAdapter),
mapMessages(messages: readonly unknown[]): unknown[] {
return openAiResponseMessagesToChatCompletions(messages as OpenAIChatMessage[]);
},
rankTools(config: RuntimeConfigSnapshot, options?: {forCreator?: boolean; vectorStoreIds?: string[]}): readonly BoundaryValue[] {
void config;
void options?.vectorStoreIds;
return getOpenAICompatibleTools(options?.forCreator) as BoundaryValue[];
},
extractTextDelta(input: unknown): string {
const chunk = input as OpenAiChatCompletionStreamChunkLike | undefined;
return chunk?.choices?.[0]?.delta?.content ?? "";
},
extractToolCalls(input: unknown): ToolCallData[] {
const response = input as OpenAiChatCompletionResponseLike | undefined;
const toolCalls = response?.choices?.[0]?.message?.tool_calls ?? [];
return toolCalls
.map((call, index) => ({
id: typeof call?.id === "string" && call.id.trim().length > 0 ? call.id : `openai_chat_${index}`,
name: typeof call?.function?.name === "string" ? call.function.name : typeof call?.name === "string" ? call.name : "",
argumentsText: typeof call?.function?.arguments === "string"
? call.function.arguments
: JSON.stringify(call?.function?.arguments ?? call?.arguments ?? {}),
}))
.filter(call => call.name.length > 0);
},
extractStreamingToolCalls(input: unknown): ToolCallData[] {
const chunk = input as OpenAiChatCompletionStreamChunkLike | undefined;
const toolCalls = chunk?.choices?.[0]?.delta?.tool_calls ?? [];
return toolCalls
.map((call, index) => ({
id: typeof call?.id === "string" && call.id.trim().length > 0
? call.id
: `openai_chat_${typeof call?.index === "number" ? call.index : index}`,
name: typeof call?.function?.name === "string" ? call.function.name : typeof call?.name === "string" ? call.name : "",
argumentsText: typeof call?.function?.arguments === "string"
? call.function.arguments
: call?.function?.arguments
? JSON.stringify(call.function.arguments)
: typeof call?.arguments === "string"
? call.arguments
: "",
}))
.filter(call => call.id.length > 0);
},
appendToolResults(messages: unknown[], calls: ToolCallData[], results: string[]): void {
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
tool_call_id: call.id,
content: results[index] ?? "",
});
}
},
finalize: baseAdapter.finalize.bind(baseAdapter),
};
}
export async function runOpenAiCompatible(
msg: Message,
messages: OpenAIChatMessage[],
streamMessage: TelegramStreamMessage,
signal: AbortSignal,
stream: boolean,
sourceMessage: Message,
config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext,
downloads: AiDownloadedFile[] = [],
): Promise<void> {
void downloads;
const runnerStartedAt = Date.now();
const openAi = createOpenAiClient(config.openAiChatTarget);
const adapter = makeChatCompletionAdapter();
const systemPrompt = buildSystemInstruction(
config,
DEFAULT_AI_RESPONSE_LANGUAGE,
false,
config.openAiChatTarget.systemPromptAdditions,
await buildUserMemoryPrompt(msg.from?.id),
);
let conversationMessages = [...openAiResponseMessagesToChatCompletions(messages)];
if (systemPrompt.trim().length) {
conversationMessages.unshift({role: "system", content: systemPrompt});
}
const availableTools = getOpenAICompatibleTools(msg.from?.id === Environment.CREATOR_ID) as ChatCompletionTool[];
aiLog("info", "openai_compatible.run.start", {
stream,
target: aiLogProviderTarget(config.openAiChatTarget),
inputMessages: messages.length,
sourceMessage: aiLogMessageIdentity(sourceMessage),
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
backend: config.openAiBackend,
});
const toolMemory: ToolExecutionMemory = new Map();
try {
await runToolLoopRounds({
maxRounds: MAX_TOOL_ROUNDS,
onRound: async (round) => {
const roundStartedAt = Date.now();
aiLog("debug", "openai_compatible.round.start", {round, inputMessages: conversationMessages.length, stream});
const rankResult = await runToolRankStage({
provider: AiProvider.OPENAI,
model: config.openAiChatTarget.model,
round,
config,
availableTools: availableTools as readonly BoundaryValue[],
messages,
streamMessage,
signal,
});
const requestTools = ensureToolsSelected(
availableTools,
rankResult.filteredTools as ChatCompletionTool[],
MEMORY_TOOL_NAMES,
);
if (!stream) {
const request: ChatCompletionCreateParamsNonStreaming = {
model: config.openAiChatTarget.model,
messages: conversationMessages,
tools: requestTools.length ? requestTools : undefined,
};
const response = await runSingleModelRequest({
execute: () => adapter.callModel(request, () => executeChatCompletionWithOptionalToolFallback<OpenAiChatCompletionResponseLike>({
openAi,
request,
signal,
stream: false,
})),
}) as OpenAiChatCompletionResponseLike;
const message = response.choices?.[0]?.message;
const responseText = typeof message?.content === "string" ? message.content : "";
streamMessage.append(responseText);
aiLog("debug", "openai_compatible.response.received", {
round,
duration: aiLogDuration(roundStartedAt),
textChars: responseText.length,
hasToolCalls: !!message?.tool_calls?.length,
});
const calls = adapter.extractToolCalls(response);
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
calls: calls.map(call => ({
id: call.id,
name: call.name,
arguments: safeJsonParseObject(call.argumentsText)
})),
});
if (!calls.length) return {shouldContinue: false};
const toolCalls = calls.map(call => ({
id: call.id,
name: call.name,
argumentsText: call.argumentsText,
}));
const toolMessages: OpenAICompatibleChatMessage[] = [];
const toolResults = await executeToolBatchWithAdapter({
userId: msg.from?.id,
toolCalls,
streamMessage,
toolContext: {
...toolContext,
provider: AiProvider.OPENAI,
runtimeTarget: config.openAiChatTarget,
},
toolMemory,
adapter,
appendTargets: [toolMessages],
});
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
if (uploadFilesResult.found && !uploadFilesResult.uploaded && uploadFilesResult.toolIndex >= 0) {
const toolMessage = toolMessages[uploadFilesResult.toolIndex];
if (toolMessage && toolMessage.role === "tool") {
toolMessage.content = "Error: " + uploadFilesResult.error;
}
}
const continuation = decideToolLoopContinuation({
round,
maxRounds: MAX_TOOL_ROUNDS,
toolCalls: calls,
});
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "openai_compatible.tool_loop.max_rounds_reached", {
round,
maxRounds: MAX_TOOL_ROUNDS,
});
}
conversationMessages = [...conversationMessages, buildAssistantToolMessage(calls, responseText), ...toolMessages];
return {shouldContinue: true};
}
const request: ChatCompletionCreateParamsStreaming = {
model: config.openAiChatTarget.model,
messages: conversationMessages,
stream: true,
tools: requestTools.length ? requestTools : undefined,
};
const response = await runSingleModelRequest({
execute: () => adapter.callModel(request, () => executeChatCompletionWithOptionalToolFallback<AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>>({
openAi,
request,
signal,
stream: true,
})),
}) as AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>;
aiLog("debug", "openai_compatible.stream.open", {round});
let responseText = "";
let toolCallState: ToolCallData[] = [];
for await (const chunk of response) {
if (signal.aborted) throw new Error("Aborted");
const deltaText = adapter.extractTextDelta(chunk);
if (deltaText) {
const appendedText = normalizeStreamingTextDelta(responseText, deltaText);
responseText += appendedText;
streamMessage.append(appendedText);
}
const streamedCalls = adapter.extractStreamingToolCalls(chunk);
if (streamedCalls.length) {
toolCallState = mergeToolCallChunks(toolCallState, streamedCalls);
const activeCalls = toolCallState.filter(call => call.name.length > 0);
aiLog("info", "openai_compatible.stream.tool_call.added", {
round,
toolCalls: activeCalls.map(aiLogToolCall),
});
streamMessage.setStatus(Environment.getUseToolText(activeCalls));
await streamMessage.flush();
}
}
const calls = toolCallState.filter(call => call.name.length > 0);
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.stream.done", {
round,
duration: aiLogDuration(roundStartedAt),
textChars: responseText.length,
calls: calls.map(call => ({
id: call.id,
name: call.name,
arguments: safeJsonParseObject(call.argumentsText)
})),
});
if (!calls.length) return {shouldContinue: false};
streamMessage.clearStatus();
await streamMessage.flush();
const toolMessages: OpenAICompatibleChatMessage[] = [];
const toolResults = await executeToolBatchWithAdapter({
userId: msg.from?.id,
toolCalls: calls,
streamMessage,
toolContext: {
...toolContext,
provider: AiProvider.OPENAI,
runtimeTarget: config.openAiChatTarget,
},
toolMemory,
adapter,
appendTargets: [toolMessages],
});
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
if (uploadFilesResult.found && !uploadFilesResult.uploaded && uploadFilesResult.toolIndex >= 0) {
const toolMessage = toolMessages[uploadFilesResult.toolIndex];
if (toolMessage && toolMessage.role === "tool") {
toolMessage.content = "Error: " + uploadFilesResult.error;
}
}
const continuation = decideToolLoopContinuation({
round,
maxRounds: MAX_TOOL_ROUNDS,
toolCalls: calls,
});
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "openai_compatible.tool_loop.max_rounds_reached", {
round,
maxRounds: MAX_TOOL_ROUNDS,
});
}
conversationMessages = [...conversationMessages, buildAssistantToolMessage(calls, responseText), ...toolMessages];
return {shouldContinue: true};
},
});
} catch (error) {
aiLog("error", "openai_compatible.run.failed", {
duration: aiLogDuration(runnerStartedAt),
error: describeOpenAiCompatibleError(error),
});
throw error;
} finally {
await adapter.finalize().catch(logError);
}
}
+330 -339
View File
@@ -1,6 +1,7 @@
// OpenAI and OpenAI-compatible provider runners extracted from unified-ai-runner.ts.
import {Message} from "typescript-telegram-bot-api"; import {Message} from "typescript-telegram-bot-api";
import {OpenAI, toFile} from "openai";
import {Environment} from "../common/environment"; import {Environment} from "../common/environment";
import {getOpenAITools} from "./tool-mappers";
import {TelegramStreamMessage} from "./telegram-stream-message"; import {TelegramStreamMessage} from "./telegram-stream-message";
import {ToolRuntimeContext} from "./tools/runtime"; import {ToolRuntimeContext} from "./tools/runtime";
import {OpenAIChatMessage} from "./openai-chat-message"; import {OpenAIChatMessage} from "./openai-chat-message";
@@ -10,41 +11,44 @@ import type {
ResponseInputItem, ResponseInputItem,
ResponseStreamEvent ResponseStreamEvent
} from "openai/resources/responses/responses"; } from "openai/resources/responses/responses";
import {createOpenAiClient} from "./ai-runtime-target"; import type {
ChatCompletionCreateParamsNonStreaming,
ChatCompletionCreateParamsStreaming
} from "openai/resources/chat/completions";
import {createGeminiOpenAiClient, createOpenAiClient} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger"; import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
import { import {
AsyncIterableStream, AsyncIterableStream,
buildSystemInstruction, collectOpenAiResponseFunctionCalls,
collectOpenAiResponseCodeInterpreterCalls,
collectOpenAiResponseImages, collectOpenAiResponseImages,
collectOpenAiResponseText, collectOpenAiResponseText,
executeToolBatch,
getOpenAIResponsesToolsWithImage,
isRecord,
MAX_TOOL_ROUNDS, MAX_TOOL_ROUNDS,
OPENAI_IMAGE_PARTIALS, OPENAI_IMAGE_PARTIALS,
OpenAiChatCompletionResponseLike,
OpenAiChatCompletionStreamChunkLike,
OpenAiChatToolCallLike,
OpenAiCompatibleChatMessage,
OpenAiCompatibleContentPart,
openAiResponseItemCallId, openAiResponseItemCallId,
OpenAiResponseLike, OpenAiResponseLike,
OpenAiResponseOutputItem, OpenAiResponseOutputItem,
roundStatus,
RuntimeConfigSnapshot, RuntimeConfigSnapshot,
safeJsonParseObject, safeJsonParseObject,
showOpenAiGeneratedImage, showOpenAiGeneratedImage,
StreamingToolCallAccumulator,
ToolCallData, ToolCallData,
ToolExecutionMemory, ToolExecutionMemory
allToolSchemaNames
} from "./unified-ai-runner.shared"; } from "./unified-ai-runner.shared";
import {executeToolBatchWithAdapter} from "./tool-batch-runner"; import {GetNoteFileResult, GetNoteFileResultSchema} from "./tools/send-note-file";
import {decideToolLoopContinuation} from "./tool-loop-control"; import {bot, notesDir} from "../index";
import {runToolLoopRounds} from "./tool-loop-runner"; import fs from "node:fs";
import {runSingleModelRequest} from "./model-call-stage"; import path from "node:path";
import {ensureToolsSelected} from "./tool-mappers.js";
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
import {logError} from "../util/utils"; import {logError} from "../util/utils";
import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
import {AiDownloadedFile} from "./telegram-attachments";
import {AiProvider} from "../model/ai-provider";
import {getProviderAdapter} from "./provider-adapters";
import {runToolRankStage} from "./tool-rank-stage";
import {tryToUploadFiles} from "./openai-upload-files.js";
export async function runOpenAi( export async function runOpenAi(
msg: Message, msg: Message,
@@ -52,30 +56,16 @@ export async function runOpenAi(
streamMessage: TelegramStreamMessage, streamMessage: TelegramStreamMessage,
signal: AbortSignal, signal: AbortSignal,
stream: boolean, stream: boolean,
firstRoundStatus: string,
sourceMessage: Message, sourceMessage: Message,
config: RuntimeConfigSnapshot, config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext, toolContext: ToolRuntimeContext,
downloads: AiDownloadedFile[] = [],
documentRag?: OpenAiDocumentRagContext,
): Promise<void> { ): Promise<void> {
// TODO: 13.05.2026: remove
firstRoundStatus;
const runnerStartedAt = Date.now(); const runnerStartedAt = Date.now();
let responseInput: unknown[] = [...messages];
const openAi = createOpenAiClient(config.openAiChatTarget); const openAi = createOpenAiClient(config.openAiChatTarget);
const ownsDocumentRag = !documentRag;
const preparedDocumentRag = documentRag ?? await prepareOpenAiDocumentRag(openAi, downloads.filter(download => download.kind === "document"));
const adapter = getProviderAdapter(AiProvider.OPENAI);
let responseInput: Array<ResponseInputItem | OpenAiResponseOutputItem> = adapter.mapMessages(messages) as unknown as Array<ResponseInputItem | OpenAiResponseOutputItem>;
const availableTools = adapter.rankTools(config, {
forCreator: msg.from?.id === Environment.CREATOR_ID,
vectorStoreIds: preparedDocumentRag?.vectorStoreIds ?? [],
});
const systemPrompt = buildSystemInstruction(
config,
DEFAULT_AI_RESPONSE_LANGUAGE,
false,
config.openAiChatTarget.systemPromptAdditions,
await buildUserMemoryPrompt(msg.from?.id),
);
aiLog("info", "openai.run.start", { aiLog("info", "openai.run.start", {
stream, stream,
@@ -88,51 +78,18 @@ export async function runOpenAi(
const toolMemory: ToolExecutionMemory = new Map(); const toolMemory: ToolExecutionMemory = new Map();
try { for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
await runToolLoopRounds({
maxRounds: MAX_TOOL_ROUNDS,
onRound: async (round) => {
const roundStartedAt = Date.now(); const roundStartedAt = Date.now();
aiLog("debug", "openai.round.start", {round, inputItems: responseInput.length, stream}); aiLog("debug", "openai.round.start", {round, inputItems: responseInput.length, stream});
const rankResult = await runToolRankStage({
provider: AiProvider.OPENAI,
model: config.openAiChatTarget.model,
round,
config,
availableTools,
messages,
streamMessage,
signal,
});
const filteredTools = rankResult.filteredTools;
const requestTools = preparedDocumentRag?.vectorStoreIds.length
? (() => {
const tools = [...filteredTools];
const hasFileSearch = allToolSchemaNames(tools).includes("file_search");
if (!hasFileSearch) {
const fileSearchTool = availableTools.find(tool => allToolSchemaNames([tool]).includes("file_search"));
if (fileSearchTool) {
tools.unshift(fileSearchTool);
}
}
const withMemory = ensureToolsSelected(availableTools, tools, MEMORY_TOOL_NAMES);
return withMemory.length ? withMemory : undefined;
})()
: (() => {
const withMemory = ensureToolsSelected(availableTools, filteredTools, MEMORY_TOOL_NAMES);
return withMemory.length ? withMemory : undefined;
})();
if (!stream) { if (!stream) {
const request: ResponseCreateParamsNonStreaming = { const request: ResponseCreateParamsNonStreaming = {
model: config.openAiChatTarget.model, model: config.openAiChatTarget.model,
input: responseInput as ResponseInputItem[], input: responseInput as ResponseInputItem[],
tools: requestTools as ResponseCreateParamsNonStreaming["tools"], tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsNonStreaming["tools"],
instructions: systemPrompt, instructions: config.systemPrompt,
}; };
const response = await runSingleModelRequest({ const response = await openAi.responses.create(request, {signal}) as unknown as OpenAiResponseLike;
execute: () => adapter.callModel(request, () => openAi.responses.create(request, {signal})),
}) as OpenAiResponseLike;
const responseText = collectOpenAiResponseText(response); const responseText = collectOpenAiResponseText(response);
streamMessage.append(responseText); streamMessage.append(responseText);
@@ -154,85 +111,57 @@ export async function runOpenAi(
); );
} }
const codeInterpreterCalls = collectOpenAiResponseCodeInterpreterCalls(response); const calls = collectOpenAiResponseFunctionCalls(response);
if (codeInterpreterCalls.length) {
aiLog("info", "openai.code_interpreter_calls", {
round,
duration: aiLogDuration(roundStartedAt),
calls: codeInterpreterCalls.map(call => ({
id: call.id,
status: call.status,
containerId: call.containerId,
codeChars: call.code?.length ?? 0,
outputItems: call.outputs.length,
})),
});
}
const calls = adapter.extractToolCalls(response);
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", { aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
round, round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt), duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
calls: calls.map(call => ({ calls: calls.map(call => ({
id: call.id, id: call.callId,
name: call.name, name: call.name,
arguments: safeJsonParseObject(call.argumentsText) arguments: safeJsonParseObject(call.argumentsText)
})), })),
}); });
if (!calls.length) return {shouldContinue: false}; if (!calls.length) return;
const toolCalls = calls.map(call => ({ const toolCalls = calls.map(call => ({
id: call.id, id: call.callId,
name: call.name, name: call.name,
argumentsText: call.argumentsText, argumentsText: call.argumentsText,
})); }));
const toolOutputs: Array<{type: "function_call_output"; call_id: string; output: string}> = []; const toolResults = await executeToolBatch(toolCalls, streamMessage, toolContext, toolMemory);
const toolResults = await executeToolBatchWithAdapter({
userId: msg.from?.id, let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
toolCalls,
streamMessage, for (const toolResult of toolResults) {
toolContext: { try {
...toolContext, const raw = JSON.parse(toolResult);
provider: AiProvider.OPENAI, const res = GetNoteFileResultSchema.safeParse(raw);
runtimeTarget: config.openAiChatTarget,
if (res.success && res.data.success) {
successGetNoteFileResult = res.data;
}
} catch {
// Not every tool result is JSON.
}
}
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
}, },
toolMemory, document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
adapter, }).catch(logError);
appendTargets: [toolOutputs],
});
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
if (uploadFilesResult.found) {
if (!uploadFilesResult.uploaded) {
const old = toolOutputs[uploadFilesResult.toolIndex];
const callId = old?.call_id;
if (uploadFilesResult.toolIndex >= 0) {
delete toolOutputs[uploadFilesResult.toolIndex];
} }
if (callId) {
toolOutputs.push({ const toolOutputs = calls.map((call, index) => ({
type: "function_call_output" as const, type: "function_call_output" as const,
call_id: callId, call_id: call.callId,
output: "Error: " + uploadFilesResult.error output: toolResults[index] ?? "",
}); }));
}
}
}
const continuation = decideToolLoopContinuation({
round,
maxRounds: MAX_TOOL_ROUNDS,
toolCalls: calls,
});
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "openai.tool_loop.max_rounds_reached", {
round,
maxRounds: MAX_TOOL_ROUNDS,
});
}
responseInput = [...responseInput, ...(response.output ?? []), ...toolOutputs]; responseInput = [...responseInput, ...(response.output ?? []), ...toolOutputs];
return {shouldContinue: true}; continue;
} }
let completedResponse: OpenAiResponseLike | null = null; let completedResponse: OpenAiResponseLike | null = null;
@@ -240,13 +169,9 @@ export async function runOpenAi(
model: config.openAiChatTarget.model, model: config.openAiChatTarget.model,
input: responseInput as ResponseInputItem[], input: responseInput as ResponseInputItem[],
stream: true, stream: true,
tools: requestTools as ResponseCreateParamsStreaming["tools"], tools: getOpenAIResponsesToolsWithImage(config) as ResponseCreateParamsStreaming["tools"],
parallel_tool_calls: true,
instructions: systemPrompt
}; };
const response = await runSingleModelRequest({ const response = await openAi.responses.create(request, {signal}) as unknown as AsyncIterableStream<ResponseStreamEvent>;
execute: () => adapter.callModel(request, () => openAi.responses.create(request, {signal})),
}) as AsyncIterableStream<ResponseStreamEvent>;
aiLog("debug", "openai.stream.open", {round}); aiLog("debug", "openai.stream.open", {round});
@@ -256,7 +181,7 @@ export async function runOpenAi(
switch (event.type) { switch (event.type) {
case "response.output_text.delta": case "response.output_text.delta":
streamMessage.append(adapter.extractTextDelta(event)); streamMessage.append(event.delta ?? "");
break; break;
case "response.image_generation_call.in_progress": case "response.image_generation_call.in_progress":
streamMessage.setStatus(Environment.startingImageGenText); streamMessage.setStatus(Environment.startingImageGenText);
@@ -282,33 +207,15 @@ export async function runOpenAi(
streamMessage.setStatus(Environment.finalizingImageGenText); streamMessage.setStatus(Environment.finalizingImageGenText);
await streamMessage.flush(); await streamMessage.flush();
break; break;
case "response.file_search_call.in_progress":
case "response.file_search_call.searching":
streamMessage.setStatus(Environment.getUseToolText(["file_search"]));
await streamMessage.flush();
break;
case "response.file_search_call.completed":
streamMessage.clearStatus();
await streamMessage.flush();
break;
case "response.code_interpreter_call.in_progress":
case "response.code_interpreter_call.interpreting":
streamMessage.setStatus(Environment.getUseToolText(["code_interpreter"]));
await streamMessage.flush();
break;
case "response.code_interpreter_call.completed":
streamMessage.clearStatus();
await streamMessage.flush();
break;
case "response.code_interpreter_call_code.delta":
case "response.code_interpreter_call_code.done":
break;
case "response.output_item.added": case "response.output_item.added":
{ if (event.item.type === "function_call" && event.item.name) {
const streamedCalls = adapter.extractStreamingToolCalls(event); const item = event.item as OpenAiResponseOutputItem & { id?: string };
if (streamedCalls.length) { localToolCalls.push({
localToolCalls.push(...streamedCalls); id: openAiResponseItemCallId(item),
} name: item.name ?? "",
argumentsText: item.arguments ?? "{}",
});
aiLog("info", "openai.stream.tool_call.added", { aiLog("info", "openai.stream.tool_call.added", {
round, round,
toolCalls: localToolCalls.map(aiLogToolCall) toolCalls: localToolCalls.map(aiLogToolCall)
@@ -339,7 +246,7 @@ export async function runOpenAi(
break; break;
case "response.completed": case "response.completed":
completedResponse = event.response as OpenAiResponseLike; completedResponse = event.response as unknown as OpenAiResponseLike;
break; break;
case "response.failed": case "response.failed":
throw new Error(event.response?.error?.message ?? "OpenAI response failed"); throw new Error(event.response?.error?.message ?? "OpenAI response failed");
@@ -368,205 +275,289 @@ export async function runOpenAi(
); );
} }
const codeInterpreterCalls = collectOpenAiResponseCodeInterpreterCalls(completedResponse); const calls = collectOpenAiResponseFunctionCalls(completedResponse);
if (codeInterpreterCalls.length) {
aiLog("info", "openai.code_interpreter_calls", {
round,
duration: aiLogDuration(roundStartedAt),
calls: codeInterpreterCalls.map(call => ({
id: call.id,
status: call.status,
containerId: call.containerId,
codeChars: call.code?.length ?? 0,
outputItems: call.outputs.length,
})),
});
}
const calls = adapter.extractToolCalls(completedResponse);
aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", { aiLog(calls.length ? "info" : "success", calls.length ? "openai.tool_calls" : "openai.run.done", {
round, round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt), duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
calls: calls.map(call => ({ calls: calls.map(call => ({
id: call.id, id: call.callId,
name: call.name, name: call.name,
arguments: safeJsonParseObject(call.argumentsText) arguments: safeJsonParseObject(call.argumentsText)
})), })),
}); });
if (!calls.length) return {shouldContinue: false}; if (!calls.length) return;
const toolCalls = calls.map(call => ({ const toolCalls = calls.map(call => ({
id: call.id, id: call.callId,
name: call.name, name: call.name,
argumentsText: call.argumentsText, argumentsText: call.argumentsText,
})); }));
const toolOutputs: Array<{type: "function_call_output"; call_id: string; output: string}> = []; const toolResults = await executeToolBatch(toolCalls, streamMessage, toolContext, toolMemory);
const toolResults = await executeToolBatchWithAdapter({
userId: msg.from?.id, let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
toolCalls,
streamMessage, for (const toolResult of toolResults) {
toolContext: { try {
...toolContext, const raw = JSON.parse(toolResult);
provider: AiProvider.OPENAI, const res = GetNoteFileResultSchema.safeParse(raw);
runtimeTarget: config.openAiChatTarget,
if (res.success && res.data.success) {
successGetNoteFileResult = res.data;
}
} catch {
// Not every tool result is JSON.
}
}
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
}, },
toolMemory, document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
adapter, }).catch(logError);
appendTargets: [toolOutputs],
});
const uploadFilesResult = await tryToUploadFiles(msg, toolResults);
if (uploadFilesResult.found) {
if (!uploadFilesResult.uploaded) {
const old = toolOutputs[uploadFilesResult.toolIndex];
const callId = old?.call_id;
if (uploadFilesResult.toolIndex >= 0) {
delete toolOutputs[uploadFilesResult.toolIndex];
}
if (callId) {
toolOutputs.push({
type: "function_call_output" as const,
call_id: callId,
output: "Error: " + uploadFilesResult.error
});
}
}
}
const continuation = decideToolLoopContinuation({
round,
maxRounds: MAX_TOOL_ROUNDS,
toolCalls: calls,
});
if (!continuation.continue && continuation.reason === "max_rounds_reached") {
aiLog("warn", "openai.tool_loop.max_rounds_reached", {
round,
maxRounds: MAX_TOOL_ROUNDS,
});
} }
const toolOutputs = calls.map((call, index) => ({
type: "function_call_output",
call_id: call.callId,
output: toolResults[index] ?? "",
}));
responseInput = [...responseInput, ...(completedResponse.output ?? []), ...toolOutputs]; responseInput = [...responseInput, ...(completedResponse.output ?? []), ...toolOutputs];
return {shouldContinue: true};
},
});
} finally {
if (ownsDocumentRag) {
await preparedDocumentRag?.cleanup().catch(logError);
}
await adapter.finalize().catch(logError);
} }
} }
export type OpenAiDocumentRagContext = {
vectorStoreIds: string[];
uploadedFileIds: string[];
cleanup: () => Promise<void>;
};
export async function prepareOpenAiDocumentRag(openAi: OpenAI, downloads: AiDownloadedFile[]): Promise<OpenAiDocumentRagContext | undefined> { function openAiResponseContentToText(content: unknown): string {
if (!downloads.length) return undefined; if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content.map(part => isRecord(part) ? part.text ?? part.content ?? part.refusal ?? "" : "").join("");
}
const vectorStore = await openAi.vectorStores.create({ function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAiCompatibleChatMessage[] {
name: `tg-chat-bot-${Date.now()}`, return messages.map((message): OpenAiCompatibleChatMessage => {
description: "Temporary document RAG for a single Telegram request.", if (message.role === "system" || message.role === "assistant") {
expires_after: { return {
anchor: "last_active_at", role: message.role,
days: 1, content: openAiResponseContentToText(message.content),
}, };
});
const uploadedFileIds: string[] = [];
try {
for (const download of downloads) {
const uploaded = await openAi.files.create({
file: await toFile(download.buffer, download.fileName, {
type: download.mimeType ?? "application/octet-stream",
}),
purpose: "user_data",
});
uploadedFileIds.push(uploaded.id);
} }
const batch = await openAi.vectorStores.fileBatches.createAndPoll(vectorStore.id, { const content = Array.isArray(message.content)
file_ids: uploadedFileIds, ? message.content.map((part): OpenAiCompatibleContentPart => {
}); if (isRecord(part) && part.type === "input_image") {
return {
if (batch.file_counts.failed > 0) { type: "image_url",
throw new Error(`OpenAI file_search failed to index ${batch.file_counts.failed} document(s).`); image_url: {url: String(part.image_url ?? "")},
};
} }
return { return {
vectorStoreIds: [vectorStore.id], type: "text",
uploadedFileIds, text: isRecord(part) && typeof part.text === "string" ? part.text : "",
cleanup: async () => {
await cleanupOpenAiDocumentRag(openAi, vectorStore.id, uploadedFileIds);
},
}; };
} catch (error) { })
await cleanupOpenAiDocumentRag(openAi, vectorStore.id, uploadedFileIds).catch(() => undefined); : message.content;
throw error;
return {role: "user", content};
});
}
function normalizeOpenAiChatToolCalls(toolCalls: OpenAiChatToolCallLike[] = []): ToolCallData[] {
return toolCalls.map((call, i) => ({
id: call.id || `openai_chat_${Date.now()}_${i}`,
name: call.function?.name || call.name || "",
argumentsText: typeof call.function?.arguments === "string"
? call.function.arguments
: JSON.stringify(call.function?.arguments ?? call.arguments ?? {}),
})).filter(call => call.name);
}
async function appendOpenAiChatToolResults(
messages: OpenAiCompatibleChatMessage[],
calls: ToolCallData[],
results: string[],
): Promise<void> {
for (const [index, call] of calls.entries()) {
messages.push({
role: "tool",
tool_call_id: call.id,
content: results[index] ?? "",
});
} }
} }
async function cleanupOpenAiDocumentRag(openAi: OpenAI, vectorStoreId: string, fileIds: string[]): Promise<void> { export async function runOpenAiCompatibleChat(
await openAi.vectorStores.delete(vectorStoreId).catch(() => undefined); msg: Message,
for (const fileId of fileIds) { messages: OpenAIChatMessage[],
await openAi.files.delete(fileId).catch(() => undefined); streamMessage: TelegramStreamMessage,
signal: AbortSignal,
stream: boolean,
firstRoundStatus: string,
config: RuntimeConfigSnapshot,
toolContext: ToolRuntimeContext,
): Promise<void> {
const runnerStartedAt = Date.now();
const geminiOpenAi = createGeminiOpenAiClient(config.geminiChatTarget);
const chatMessages = openAiResponseMessagesToChatCompletions(messages);
const toolMemory: ToolExecutionMemory = new Map();
aiLog("info", "openai_compatible.run.start", {
stream,
target: aiLogProviderTarget(config.geminiChatTarget),
inputMessages: messages.length,
chatMessages: chatMessages.length,
hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
});
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const roundStartedAt = Date.now();
aiLog("debug", "openai_compatible.round.start", {round, messages: chatMessages.length, stream});
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
await streamMessage.flush();
if (!stream) {
const request: ChatCompletionCreateParamsNonStreaming = {
model: config.geminiChatTarget.model,
messages: chatMessages,
tools: getOpenAITools(),
temperature: 0.6,
};
const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as OpenAiChatCompletionResponseLike;
const message = response.choices?.[0]?.message;
streamMessage.append(message?.content ?? "");
const calls = normalizeOpenAiChatToolCalls(message?.tool_calls ?? []);
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: message?.content?.length ?? 0,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
chatMessages.push({
role: "assistant",
content: message?.content ?? "",
tool_calls: calls.map(call => ({
id: call.id,
type: "function" as const,
function: {
name: call.name,
arguments: call.argumentsText,
},
})),
});
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
for (const toolResult of toolResults) {
try {
const raw = JSON.parse(toolResult);
const res = GetNoteFileResultSchema.safeParse(raw);
if (res.success && res.data.success) {
successGetNoteFileResult = res.data;
}
} catch {
// Not every tool result is JSON.
}
}
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
}).catch(logError);
}
await appendOpenAiChatToolResults(chatMessages, calls, toolResults);
continue;
}
const request: ChatCompletionCreateParamsStreaming = {
model: config.geminiChatTarget.model,
messages: chatMessages,
tools: getOpenAITools(),
temperature: 0.6,
stream: true,
};
const response = await geminiOpenAi.chat.completions.create(request, {signal}) as unknown as AsyncIterableStream<OpenAiChatCompletionStreamChunkLike>;
aiLog("debug", "openai_compatible.stream.open", {round});
// const streamToolCalls: OpenAiChatToolCallLike[] = [];
const roundTextStart = streamMessage.getText().length;
const toolCallAccumulator = new StreamingToolCallAccumulator("openai_chat_stream", round);
let calls: ToolCallData[] = [];
for await (const chunk of response) {
if (signal.aborted) throw new Error("Aborted");
const delta = chunk.choices?.[0]?.delta;
streamMessage.append(delta?.content ?? "");
if (delta?.tool_calls?.length) {
calls = toolCallAccumulator.add(delta.tool_calls);
streamMessage.setStatus(Environment.getUseToolText(calls));
await streamMessage.flush();
}
}
// const calls = collectOpenAiChatStreamToolCalls(streamToolCalls);
aiLog(calls.length ? "info" : "success", calls.length ? "openai_compatible.tool_calls" : "openai_compatible.run.done", {
round,
duration: calls.length ? aiLogDuration(roundStartedAt) : aiLogDuration(runnerStartedAt),
textChars: streamMessage.getText().slice(roundTextStart).length,
calls: calls.map(aiLogToolCall),
});
if (!calls.length) return;
const roundText = streamMessage.getText().slice(roundTextStart);
chatMessages.push({
role: "assistant",
content: roundText,
tool_calls: calls.map(call => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: call.argumentsText,
},
})),
});
const toolResults = await executeToolBatch(calls, streamMessage, toolContext, toolMemory);
let successGetNoteFileResult: GetNoteFileResult | undefined = undefined;
for (const toolResult of toolResults) {
try {
const raw = JSON.parse(toolResult);
const res = GetNoteFileResultSchema.safeParse(raw);
if (res.success && res.data.success) {
successGetNoteFileResult = res.data;
}
} catch {
// Not every tool result is JSON.
}
}
if (successGetNoteFileResult && "attachment" in successGetNoteFileResult) {
await bot.sendDocument({
chat_id: msg.chat.id,
reply_parameters: {
message_id: msg.message_id,
},
document: fs.createReadStream(path.join(notesDir, successGetNoteFileResult.attachment.relativePath)),
}).catch(logError);
}
await appendOpenAiChatToolResults(chatMessages, calls, toolResults);
} }
} }
// function openAiResponseContentToText(content: string | readonly { text?: string; refusal?: string }[]): string {
// if (typeof content === "string") return content;
// if (!Array.isArray(content)) return "";
// return content.map(part => isRecord(part) ? part.text ?? part.content ?? part.refusal ?? "" : "").join("");
// function openAiResponseMessagesToChatCompletions(messages: OpenAIChatMessage[]): OpenAiCompatibleChatMessage[] {
// return messages.map((message): OpenAiCompatibleChatMessage => {
// if (message.role === "system" || message.role === "assistant") {
// return {
// role: message.role,
// content: openAiResponseContentToText(message.content),
// };
// }
//
// const content = Array.isArray(message.content)
// ? message.content.map((part): OpenAiCompatibleContentPart => {
// if (isRecord(part) && part.type === "input_image") {
// return {
// type: "image_url",
// image_url: {url: String(part.image_url ?? "")},
// };
// }
//
// return {
// type: "text",
// text: isRecord(part) && typeof part.text === "string" ? part.text : "",
// };
// })
// : message.content;
//
// return {role: "user", content};
// });
// }
// function normalizeOpenAiChatToolCalls(toolCalls: OpenAiChatToolCallLike[] = []): ToolCallData[] {
// return toolCalls.map((call, i) => ({
// id: call.id || `openai_chat_${Date.now()}_${i}`,
// name: call.function?.name || call.name || "",
// argumentsText: typeof call.function?.arguments === "string"
// ? call.function.arguments
// : JSON.stringify(call.function?.arguments ?? call.arguments ?? {}),
// })).filter(call => call.name);
// }
// async function appendOpenAiChatToolResults(
// messages: OpenAiCompatibleChatMessage[],
// calls: ToolCallData[],
// results: string[],
// ): Promise<void> {
// for (const [index, call] of calls.entries()) {
// messages.push({
// role: "tool",
// tool_call_id: call.id,
// content: results[index] ?? "",
// });
// }
// }
+331 -222
View File
@@ -1,40 +1,41 @@
import {Message} from "typescript-telegram-bot-api"; import {Message} from "typescript-telegram-bot-api";
import * as fs from "node:fs"; import * as fs from "node:fs";
import {Blob} from "node:buffer";
import path from "node:path"; import path from "node:path";
import type {BoundaryValue} from "../common/boundary-types"; import {AiProvider} from "../model/ai-provider";
import {AiProvider} from "../model/ai-provider.js"; import {Environment} from "../common/environment";
import {ToolRankerFallbackPolicy} from "../common/policies.js"; import {photoGenDir} from "../index";
import {Environment, type OpenAiBackend} from "../common/environment.js"; import {collectReplyChainText, delay, logError, replyToMessage} from "../util/utils";
import {delay, logError, replyToMessage} from "../util/utils.js"; import {MessageStore} from "../common/message-store";
import {MessageStore} from "../common/message-store.js"; import type {OpenAiResponseTool} from "./tool-mappers";
import type {OpenAiResponseTool} from "./tool-mappers.js"; import {AiProviderName, getOpenAIResponsesTools} from "./tool-mappers";
import {AiProviderName, getOpenAICodeInterpreterTool, getOpenAIResponsesTools} from "./tool-mappers.js"; import {TelegramArtifactFile, TelegramStreamMessage} from "./telegram-stream-message";
import {TelegramArtifactFile, TelegramStreamMessage} from "./telegram-stream-message.js"; import {AiDownloadedFile} from "./telegram-attachments";
import {AiDownloadedFile} from "./telegram-attachments.js"; import {getRuntimeCapabilities} from "./provider-model-runtime";
import {getRuntimeCapabilities} from "./provider-model-runtime.js"; import {StoredAttachment} from "../model/stored-attachment";
import {StoredAttachment} from "../model/stored-attachment.js"; import {AiChatMessage, ChatMessage} from "./chat-messages-types";
import {AiChatMessage, ChatMessage} from "./chat-messages-types.js";
import {ListResponse, Ollama} from "ollama"; import {ListResponse, Ollama} from "ollama";
import {executeToolCall, ToolRuntimeContext} from "./tools/runtime.js"; import {executeToolCall, ToolRuntimeContext} from "./tools/runtime";
import {MessageImagePart, MessagePart} from "../common/message-part.js"; import {MessageImagePart, MessagePart} from "../common/message-part";
import {KeyedAsyncLock} from "../util/async-lock.js"; import {KeyedAsyncLock} from "../util/async-lock";
import {type AiRequestQueueTarget} from "./provider-request-queue.js"; import {type AiRequestQueueTarget} from "./provider-request-queue";
import {PYTHON_INTERPRETER_TOOL_NAME, pythonInterpreterToolPrompt} from "./tools/python-interpretator.js"; import {PYTHON_INTERPRETER_TOOL_NAME, pythonInterpreterToolPrompt} from "./tools/python-interpretator";
import {getResponseLanguageInstruction, UserAiResponseLanguage, UserAiVoiceMode} from "../common/user-ai-settings.js"; import {getResponseLanguageInstruction, UserAiResponseLanguage, UserAiVoiceMode} from "../common/user-ai-settings";
import { import {
isTranscribableAudioDownload, isTranscribableAudioDownload,
resolveSpeechToTextProviderForUser, resolveSpeechToTextProviderForUser,
transcribeSpeechDownloads transcribeSpeechDownloads
} from "./speech-to-text.js"; } from "./speech-to-text";
import {OpenAIChatMessage} from "./openai-chat-message";
import type {ResponseInputContent, ResponseInputMessageContentList} from "openai/resources/responses/responses";
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions"; import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
import {MistralChatMessage} from "./mistral-chat-message.js"; import type {GenerateContentParameters} from "@google/genai";
import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer.js"; import {MistralChatMessage} from "./mistral-chat-message";
import {AiRuntimeTarget, createMistralClient, resolveAiRuntimeTarget} from "./ai-runtime-target.js"; import {OllamaChatMessage} from "./ollama-chat-message";
import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger.js"; import {GeminiMessage} from "./gemini-chat-message";
import {buildConversationSnapshot, serializeConversationSnapshot} from "./conversation-pipeline.js"; import {prepareTelegramMarkdownV2} from "../util/markdown-v2-renderer";
import type {ResponseInputMessageContentList} from "openai/resources/responses/responses"; import {AiRuntimeTarget, createMistralClient, getGeminiApiMode, resolveAiRuntimeTarget} from "./ai-runtime-target";
import {persistToolResultArtifactAttachment} from "./tool-result-artifact-store.js"; import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
import {filterUserInputStoredAttachments} from "../common/attachment-visibility.js";
export type {Message} from "typescript-telegram-bot-api"; export type {Message} from "typescript-telegram-bot-api";
export type {AiRuntimeTarget} from "./ai-runtime-target"; export type {AiRuntimeTarget} from "./ai-runtime-target";
@@ -46,6 +47,7 @@ export type {MessageImagePart, MessagePart} from "../common/message-part";
export type {OpenAIChatMessage} from "./openai-chat-message"; export type {OpenAIChatMessage} from "./openai-chat-message";
export type {MistralChatMessage} from "./mistral-chat-message"; export type {MistralChatMessage} from "./mistral-chat-message";
export type {OllamaChatMessage} from "./ollama-chat-message"; export type {OllamaChatMessage} from "./ollama-chat-message";
export type {GeminiMessage} from "./gemini-chat-message";
export type {TelegramArtifactFile} from "./telegram-stream-message"; export type {TelegramArtifactFile} from "./telegram-stream-message";
export {TelegramStreamMessage} from "./telegram-stream-message"; export {TelegramStreamMessage} from "./telegram-stream-message";
export type {ChatRequest, ListResponse, Ollama, Tool} from "ollama"; export type {ChatRequest, ListResponse, Ollama, Tool} from "ollama";
@@ -61,8 +63,10 @@ export type {
ChatCompletionCreateParamsStreaming, ChatCompletionCreateParamsStreaming,
ChatCompletionMessageParam, ChatCompletionMessageParam,
} from "openai/resources/chat/completions"; } from "openai/resources/chat/completions";
export type {GenerateContentParameters} from "@google/genai";
export const TELEGRAM_LIMIT = 4096; export const TELEGRAM_LIMIT = 4096;
export const MAX_TOOL_ROUNDS = 40; export const MAX_TOOL_ROUNDS = 12;
export const MAX_IDENTICAL_TOOL_CALLS = 1; export const MAX_IDENTICAL_TOOL_CALLS = 1;
export const OPENAI_IMAGE_PARTIALS = 3; export const OPENAI_IMAGE_PARTIALS = 3;
export const AI_REQUEST_TIMEOUT_MS = 10 * 60 * 1000; export const AI_REQUEST_TIMEOUT_MS = 10 * 60 * 1000;
@@ -71,19 +75,13 @@ export const MAX_OLLAMA_CONTEXT_SIZE = 262144;
export const DEFAULT_OLLAMA_CONTEXT_SIZE = 32768; export const DEFAULT_OLLAMA_CONTEXT_SIZE = 32768;
export const toolResourceLocks = new KeyedAsyncLock(); export const toolResourceLocks = new KeyedAsyncLock();
function photoGenDir(): string {
return path.join(Environment.DATA_PATH, "cache", "photo", "gen");
}
export type UnifiedRunOptions = { export type UnifiedRunOptions = {
provider: AiProvider; provider: AiProvider;
msg: Message; msg: Message;
requestId?: string;
isGuestMsg?: boolean; isGuestMsg?: boolean;
text: string; text: string;
stream?: boolean; stream?: boolean;
think?: Think; think?: Think;
synthesizeSpeechResponse?: boolean;
responseLanguage?: UserAiResponseLanguage; responseLanguage?: UserAiResponseLanguage;
contextSize?: number; contextSize?: number;
voiceMode?: UserAiVoiceMode; voiceMode?: UserAiVoiceMode;
@@ -101,7 +99,7 @@ export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
export type JsonObject = { [key: string]: JsonValue }; export type JsonObject = { [key: string]: JsonValue };
// SDKs sometimes expose loose object-shaped payloads. Keep the looseness at the boundary, // SDKs sometimes expose loose object-shaped payloads. Keep the looseness at the boundary,
// but do not spread it through the rest of the code. // but do not spread `unknown` through the rest of the code.
export type LooseRecord = Record<string, JsonValue | object | undefined>; export type LooseRecord = Record<string, JsonValue | object | undefined>;
export type OpenAiResponsesFunctionCall = { export type OpenAiResponsesFunctionCall = {
@@ -165,16 +163,11 @@ export type OpenAiChatToolCallLike = {
export type OpenAiResponseOutputItem = { export type OpenAiResponseOutputItem = {
type?: string; type?: string;
id?: string;
call_id?: string; call_id?: string;
name?: string; name?: string;
arguments?: string; arguments?: string;
result?: string; result?: string;
content?: Array<{ text?: string; refusal?: string }>; content?: Array<{ text?: string; refusal?: string }>;
code?: string | null;
container_id?: string;
outputs?: Array<{ type?: "logs" | "image"; logs?: string; url?: string }> | null;
status?: string;
}; };
export type OpenAiResponseLike = { export type OpenAiResponseLike = {
@@ -183,6 +176,22 @@ export type OpenAiResponseLike = {
output_text?: string; output_text?: string;
}; };
export type GeminiFunctionCallLike = {
id?: string;
name?: string;
args?: JsonObject;
};
export type GeminiResponsePartLike = {
text?: string;
functionCall?: GeminiFunctionCallLike;
};
export type GeminiResponseLike = {
functionCalls?: GeminiFunctionCallLike[];
candidates?: Array<{ content?: { parts?: GeminiResponsePartLike[] } }>;
};
export type OpenAiCompatibleContentPart = export type OpenAiCompatibleContentPart =
| { type: "text"; text: string } | { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string } }; | { type: "image_url"; image_url: { url: string } };
@@ -199,11 +208,13 @@ export type OpenAiChatCompletionStreamChunkLike = {
export type AsyncIterableStream<T> = AsyncIterable<T>; export type AsyncIterableStream<T> = AsyncIterable<T>;
export function isRecord(value: BoundaryValue): value is LooseRecord { export type GeminiGenerationRequest = GenerateContentParameters;
export function isRecord(value: unknown): value is LooseRecord {
return value !== null && typeof value === "object" && !Array.isArray(value); return value !== null && typeof value === "object" && !Array.isArray(value);
} }
export function toJsonValue(value: BoundaryValue): JsonValue | undefined { export function toJsonValue(value: unknown): JsonValue | undefined {
if (value === null) return null; if (value === null) return null;
switch (typeof value) { switch (typeof value) {
@@ -229,16 +240,16 @@ export function toJsonValue(value: BoundaryValue): JsonValue | undefined {
} }
} }
export function toJsonObject(value: BoundaryValue): JsonObject | undefined { export function toJsonObject(value: unknown): JsonObject | undefined {
const json = toJsonValue(value); const json = toJsonValue(value);
return json !== null && typeof json === "object" && !Array.isArray(json) ? json : undefined; return json !== null && typeof json === "object" && !Array.isArray(json) ? json : undefined;
} }
export function asOptionalString(value: BoundaryValue): string | undefined { export function asOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
} }
export function isAbortError(error: BoundaryValue): boolean { export function isAbortError(error: unknown): boolean {
return error instanceof Error ? error.message.includes("Aborted") : String(error).includes("Aborted"); return error instanceof Error ? error.message.includes("Aborted") : String(error).includes("Aborted");
} }
@@ -251,11 +262,9 @@ export type RuntimeConfigSnapshot = {
useSystemPrompt: boolean; useSystemPrompt: boolean;
systemPrompt?: string; systemPrompt?: string;
rankerToolPrompt?: string; rankerToolPrompt?: string;
toolRankerFallbackPolicy: ToolRankerFallbackPolicy;
ollamaChatTarget: AiRuntimeTarget; ollamaChatTarget: AiRuntimeTarget;
ollamaToolRankerTarget?: AiRuntimeTarget; ollamaToolRankerTarget?: AiRuntimeTarget;
ollamaToolTarget: AiRuntimeTarget;
ollamaVisionTarget: AiRuntimeTarget; ollamaVisionTarget: AiRuntimeTarget;
ollamaThinkingTarget: AiRuntimeTarget; ollamaThinkingTarget: AiRuntimeTarget;
ollamaAudioTarget: AiRuntimeTarget; ollamaAudioTarget: AiRuntimeTarget;
@@ -268,13 +277,14 @@ export type RuntimeConfigSnapshot = {
ollamaRagMaxArchiveFiles: number; ollamaRagMaxArchiveFiles: number;
ollamaRagMaxArchiveBytes: number; ollamaRagMaxArchiveBytes: number;
ollamaRagMaxArchiveDepth: number; ollamaRagMaxArchiveDepth: number;
geminiChatTarget: AiRuntimeTarget;
geminiImageTarget: AiRuntimeTarget;
mistralChatTarget: AiRuntimeTarget; mistralChatTarget: AiRuntimeTarget;
mistralToolRankerTarget?: AiRuntimeTarget;
openAiChatTarget: AiRuntimeTarget; openAiChatTarget: AiRuntimeTarget;
openAiImageTarget: AiRuntimeTarget; openAiImageTarget: AiRuntimeTarget;
openAiToolRankerTarget?: AiRuntimeTarget;
openAiBackend: OpenAiBackend;
}; };
export function snapshotRuntimeConfig(): RuntimeConfigSnapshot { export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
@@ -284,11 +294,9 @@ export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
systemPrompt: Environment.SYSTEM_PROMPT, systemPrompt: Environment.SYSTEM_PROMPT,
rankerToolPrompt: Environment.RANKER_TOOL_PROMPT, rankerToolPrompt: Environment.RANKER_TOOL_PROMPT,
toolRankerFallbackPolicy: Environment.TOOL_RANKER_FALLBACK_POLICY,
ollamaChatTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat"), ollamaChatTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "chat"),
ollamaToolRankerTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "toolRank"), ollamaToolRankerTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "tools"),
ollamaToolTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "tools"),
ollamaVisionTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "vision"), ollamaVisionTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "vision"),
ollamaThinkingTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "thinking"), ollamaThinkingTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "thinking"),
ollamaAudioTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "audio"), ollamaAudioTarget: resolveAiRuntimeTarget(AiProvider.OLLAMA, "audio"),
@@ -302,20 +310,16 @@ export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
ollamaRagMaxArchiveBytes: Environment.OLLAMA_RAG_MAX_ARCHIVE_BYTES, ollamaRagMaxArchiveBytes: Environment.OLLAMA_RAG_MAX_ARCHIVE_BYTES,
ollamaRagMaxArchiveDepth: Environment.OLLAMA_RAG_MAX_ARCHIVE_DEPTH, ollamaRagMaxArchiveDepth: Environment.OLLAMA_RAG_MAX_ARCHIVE_DEPTH,
geminiChatTarget: resolveAiRuntimeTarget(AiProvider.GEMINI, "chat"),
geminiImageTarget: resolveAiRuntimeTarget(AiProvider.GEMINI, "vision"),
mistralChatTarget: resolveAiRuntimeTarget(AiProvider.MISTRAL, "chat"), mistralChatTarget: resolveAiRuntimeTarget(AiProvider.MISTRAL, "chat"),
mistralToolRankerTarget: resolveAiRuntimeTarget(AiProvider.MISTRAL, "toolRank"),
openAiChatTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "chat"), openAiChatTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "chat"),
openAiImageTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "outputImages"), openAiImageTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "outputImages"),
openAiToolRankerTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "toolRank"),
openAiBackend: Environment.OPENAI_BACKEND,
}; };
} }
export function isOpenAiCompatibleBackend(config: RuntimeConfigSnapshot): boolean {
return config.openAiBackend === "compatible";
}
export function getMessageImageParts(part: MessagePart): MessageImagePart[] { export function getMessageImageParts(part: MessagePart): MessageImagePart[] {
if (part.imageParts?.length) return part.imageParts; if (part.imageParts?.length) return part.imageParts;
return (part.images ?? []).map(data => ({data, mimeType: "image/jpeg"})); return (part.images ?? []).map(data => ({data, mimeType: "image/jpeg"}));
@@ -325,10 +329,33 @@ export function openAiImageDataUrl(image: MessageImagePart): string {
return `data:${image.mimeType || "image/jpeg"};base64,${image.data}`; return `data:${image.mimeType || "image/jpeg"};base64,${image.data}`;
} }
export function geminiAudioMimeType(mimeType: string | undefined): string {
const normalized = mimeType?.toLowerCase();
switch (normalized) {
case "audio/wav":
case "audio/mp3":
case "audio/aiff":
case "audio/aac":
case "audio/ogg":
case "audio/flac":
case "audio/mpeg":
case "audio/m4a":
case "audio/l16":
case "audio/opus":
case "audio/alaw":
case "audio/mulaw":
return normalized;
default:
return "audio/wav";
}
}
export function snapshotModel(provider: AiProvider, config: RuntimeConfigSnapshot): string { export function snapshotModel(provider: AiProvider, config: RuntimeConfigSnapshot): string {
switch (provider) { switch (provider) {
case AiProvider.OLLAMA: case AiProvider.OLLAMA:
return config.ollamaChatTarget.model; return config.ollamaChatTarget.model;
case AiProvider.GEMINI:
return config.geminiChatTarget.model;
case AiProvider.MISTRAL: case AiProvider.MISTRAL:
return config.mistralChatTarget.model; return config.mistralChatTarget.model;
case AiProvider.OPENAI: case AiProvider.OPENAI:
@@ -342,33 +369,17 @@ export function providerTargets(provider: AiProvider, config: RuntimeConfigSnaps
return [ return [
config.ollamaChatTarget, config.ollamaChatTarget,
config.ollamaToolRankerTarget, config.ollamaToolRankerTarget,
config.ollamaToolTarget,
config.ollamaVisionTarget, config.ollamaVisionTarget,
config.ollamaThinkingTarget, config.ollamaThinkingTarget,
config.ollamaAudioTarget, config.ollamaAudioTarget,
config.ollamaDocumentsTarget config.ollamaDocumentsTarget
].filter((target): target is AiRuntimeTarget => !!target); ].filter((target): target is AiRuntimeTarget => !!target);
case AiProvider.GEMINI:
return [config.geminiChatTarget];
case AiProvider.MISTRAL: case AiProvider.MISTRAL:
return [ return [config.mistralChatTarget];
config.mistralChatTarget,
config.mistralToolRankerTarget,
].filter((target): target is AiRuntimeTarget => !!target);
case AiProvider.OPENAI: case AiProvider.OPENAI:
return [ return [config.openAiChatTarget];
config.openAiChatTarget,
config.openAiToolRankerTarget,
].filter((target): target is AiRuntimeTarget => !!target);
}
}
export function providerChatTarget(provider: AiProvider, config: RuntimeConfigSnapshot): AiRuntimeTarget {
switch (provider) {
case AiProvider.OLLAMA:
return config.ollamaChatTarget;
case AiProvider.MISTRAL:
return config.mistralChatTarget;
case AiProvider.OPENAI:
return config.openAiChatTarget;
} }
} }
@@ -376,6 +387,8 @@ export function providerName(provider: AiProvider): AiProviderName {
switch (provider) { switch (provider) {
case AiProvider.OLLAMA: case AiProvider.OLLAMA:
return "ollama"; return "ollama";
case AiProvider.GEMINI:
return "gemini";
case AiProvider.MISTRAL: case AiProvider.MISTRAL:
return "mistral"; return "mistral";
case AiProvider.OPENAI: case AiProvider.OPENAI:
@@ -387,14 +400,10 @@ export function buildSystemInstruction(
config: RuntimeConfigSnapshot, config: RuntimeConfigSnapshot,
responseLanguage: UserAiResponseLanguage, responseLanguage: UserAiResponseLanguage,
includePythonToolPrompt: boolean, includePythonToolPrompt: boolean,
additions?: string | null,
memoryInstruction?: string | null,
): string { ): string {
return [ return [
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null, getResponseLanguageInstruction(responseLanguage),
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null, config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
additions?.trim() ? additions.trim() : null,
memoryInstruction?.trim() ? memoryInstruction.trim() : null,
includePythonToolPrompt ? pythonInterpreterToolPrompt : null, includePythonToolPrompt ? pythonInterpreterToolPrompt : null,
].filter(Boolean).join("\n\n"); ].filter(Boolean).join("\n\n");
} }
@@ -425,6 +434,8 @@ export function resolveAiRequestQueueTarget(
if (hasAudioAttachmentKind(requestedAttachmentKinds)) return config.ollamaAudioTarget; if (hasAudioAttachmentKind(requestedAttachmentKinds)) return config.ollamaAudioTarget;
if (requestedAttachmentKinds.has("image")) return config.ollamaVisionTarget; if (requestedAttachmentKinds.has("image")) return config.ollamaVisionTarget;
return options.think ? config.ollamaThinkingTarget : config.ollamaChatTarget; return options.think ? config.ollamaThinkingTarget : config.ollamaChatTarget;
case AiProvider.GEMINI:
return config.geminiChatTarget;
case AiProvider.MISTRAL: case AiProvider.MISTRAL:
return config.mistralChatTarget; return config.mistralChatTarget;
case AiProvider.OPENAI: case AiProvider.OPENAI:
@@ -437,10 +448,13 @@ export function roundStatus(round: number, firstRoundStatus: string, content?: s
return null; return null;
} }
return toolCalls?.length ? Environment.getUseToolText(toolCalls) const status = toolCalls?.length ? Environment.getUseToolText(toolCalls)
: thinking ? Environment.reasoningText : thinking ? Environment.reasoningText
: round === 0 ? firstRoundStatus : round === 0 ? firstRoundStatus
: Environment.waitThinkText; : Environment.waitThinkText;
return status;
} }
export function isPlainTextDocument(doc: AiDownloadedFile): boolean { export function isPlainTextDocument(doc: AiDownloadedFile): boolean {
@@ -524,13 +538,13 @@ export function addMessageAttachmentKinds(msg: Message | undefined, kinds: Set<A
if (msg.video) kinds.add("video"); if (msg.video) kinds.add("video");
} }
export async function collectStoredReplyChainAttachments(msg: Message, limit: number = 40): Promise<StoredAttachment[]> { export async function collectStoredReplyChainAttachments(msg: Message, limit: number = 1): Promise<StoredAttachment[]> {
const attachments: StoredAttachment[] = []; const attachments: StoredAttachment[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
let current = await MessageStore.get(msg.chat.id, msg.message_id); let current = await MessageStore.get(msg.chat.id, msg.message_id);
for (let i = 0; current && i < limit; i++) { for (let i = 0; current && i < limit; i++) {
for (const attachment of filterUserInputStoredAttachments(current?.attachments ?? [])) { for (const attachment of current?.attachments ?? []) {
const key = [ const key = [
attachment.kind, attachment.kind,
attachment.fileUniqueId || attachment.fileId, attachment.fileUniqueId || attachment.fileId,
@@ -550,6 +564,13 @@ export async function hasStoredReplyChainImage(msg: Message): Promise<boolean> {
const attachments = await collectStoredReplyChainAttachments(msg); const attachments = await collectStoredReplyChainAttachments(msg);
if (attachments.some(attachment => attachment.kind === "image")) return true; if (attachments.some(attachment => attachment.kind === "image")) return true;
let current = await MessageStore.get(msg.chat.id, msg.message_id);
for (let i = 0; current && i < 40; i++) {
if (current.photoMaxSizeFilePath?.length) return true;
current = await MessageStore.get(current.chatId, current.replyToMessageId);
}
return false; return false;
} }
@@ -622,6 +643,7 @@ export async function rejectUnsupportedAttachments(
if (!unsupported) return false; if (!unsupported) return false;
if (!kinds.has("audio")) { if (!kinds.has("audio")) {
// TODO: 13.05.2026, Danil Nikolaev: add "Regenerate" button
await replyToMessage({ await replyToMessage({
message: msg, message: msg,
text: unsupportedAttachmentText(provider, effectiveModel, unsupported), text: unsupportedAttachmentText(provider, effectiveModel, unsupported),
@@ -685,7 +707,7 @@ export function parseToolArgumentsObject(argumentsText?: string): ToolArgumentsP
} }
} }
export function errorMessage(error: BoundaryValue): string { export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
@@ -776,30 +798,191 @@ export function normalizeOllamaToolCalls(calls: readonly OllamaToolCallLike[] =
.filter(call => !!call.name); .filter(call => !!call.name);
} }
export function buildOpenAiResponseMessage(part: MessagePart, getContent: (part: MessagePart) => string): OpenAIChatMessage {
const content: Array<ResponseInputContent | any> = [{
type: part.bot ? "output_text" : "input_text",
text: getContent(part),
}];
if (!part.bot) {
for (const image of getMessageImageParts(part)) {
content.push({type: "input_image", image_url: openAiImageDataUrl(image), detail: "auto"});
}
}
return {role: part.bot ? "assistant" : "user", content, type: "message"};
}
export function buildGeminiMessage(part: MessagePart, getContent: (part: MessagePart) => string): GeminiMessage {
const parts: GeminiMessage["parts"] = [{text: getContent(part)}];
if (!part.bot) {
for (const image of getMessageImageParts(part)) {
parts.push({
inlineData: {
data: image.data,
mimeType: image.mimeType || "image/jpeg",
},
});
}
const audioParts = part.audioParts?.length
? part.audioParts
: (part.audios ?? []).map(data => ({data, mimeType: "audio/wav"}));
for (const audio of audioParts) {
parts.push({
inlineData: {
data: audio.data,
mimeType: geminiAudioMimeType(audio.mimeType),
},
});
}
for (const videoNote of part.videoNotes ?? []) {
parts.push({
inlineData: {
data: videoNote,
mimeType: "audio/wav",
},
});
}
}
return {
role: part.bot ? "model" : "user",
parts,
};
}
export async function collectTextMessages( export async function collectTextMessages(
msg: Message, msg: Message,
textOverride: string, textOverride: string,
provider: AiProvider, provider: AiProvider,
downloads: AiDownloadedFile[], downloads: AiDownloadedFile[],
config: RuntimeConfigSnapshot, config: RuntimeConfigSnapshot,
runtimeTarget: AiRuntimeTarget,
responseLanguage: UserAiResponseLanguage, responseLanguage: UserAiResponseLanguage,
): Promise<{ ): Promise<{
chatMessages: AiChatMessage[]; chatMessages: AiChatMessage[];
imageCount: number imageCount: number
}> { }> {
const includePythonToolPrompt = Environment.ENABLE_PYTHON_INTERPRETER && msg.from?.id === Environment.CREATOR_ID; const storedMsg = await MessageStore.get(msg.chat.id, msg.message_id);
const snapshot = await buildConversationSnapshot( const messageParts = await collectReplyChainText({triggerMsg: storedMsg, downloads: downloads});
msg,
textOverride,
downloads,
config,
runtimeTarget,
responseLanguage,
includePythonToolPrompt,
);
return serializeConversationSnapshot(snapshot, provider, Environment.USE_NAMES_IN_PROMPT); const cleanTextOverride = textOverride?.trim();
if (messageParts.length && cleanTextOverride) {
const latest = messageParts[0];
if (!latest.bot) latest.content = textOverride.trim();
}
const ordered = messageParts.reverse();
const imageCount = ordered.reduce((sum, p) => sum + (p.bot ? 0 : getMessageImageParts(p).length), 0);
const includePythonToolPrompt = Environment.ENABLE_PYTHON_INTERPRETER && msg.from?.id === Environment.CREATOR_ID;
const systemInstruction = buildSystemInstruction(config, responseLanguage, includePythonToolPrompt);
const getContent = (part: MessagePart): string => {
if (part.bot) return part.content;
const userInfo = [
"[user_info]:",
`name: ${part.name}`,
`username: @${part.userName}`,
""
].join("\n");
const finalContent = [
part.content
];
if (Environment.USE_NAMES_IN_PROMPT) {
finalContent.unshift(userInfo);
}
return finalContent.join("\n");
};
if (provider === AiProvider.OPENAI) {
const messages: OpenAIChatMessage[] = ordered.map(part => buildOpenAiResponseMessage(part, getContent));
if (systemInstruction) {
messages.unshift({role: "system", content: systemInstruction, type: "message"});
}
return {chatMessages: messages, imageCount};
}
if (provider === AiProvider.MISTRAL) {
const messages: MistralChatMessage[] = ordered.map(part => {
if (part.bot) {
return {
role: "assistant",
content: [{type: "text", text: getContent(part)}]
};
} else {
return {
role: "user",
content: [
{type: "text", text: getContent(part)},
...getMessageImageParts(part).map(p => {
return {
type: "image_url" as const,
imageUrl: `data:${p.mimeType || "image/jpeg"};base64,${p.data}`
};
})
]
};
}
});
if (systemInstruction) {
messages.unshift({role: "system", content: systemInstruction});
}
return {chatMessages: messages, imageCount};
}
if (provider === AiProvider.OLLAMA) {
const messages: OllamaChatMessage[] = ordered.map(part => ({
role: part.bot ? "assistant" : "user",
content: getContent(part),
images: part.bot ? undefined : part.images,
imageParts: part.imageParts,
audios: part.audios,
audioParts: part.audioParts,
videos: part.videos,
videoNotes: part.videoNotes
}));
if (systemInstruction) {
messages.unshift({
role: "system",
content: systemInstruction
});
}
return {chatMessages: messages, imageCount};
}
if (provider === AiProvider.GEMINI) {
if (getGeminiApiMode(config.geminiChatTarget) === "openai") {
const messages: OpenAIChatMessage[] = ordered.map(part => buildOpenAiResponseMessage(part, getContent));
if (systemInstruction) {
messages.unshift({role: "system", content: systemInstruction, type: "message"});
}
return {chatMessages: messages, imageCount};
}
const messages: GeminiMessage[] = ordered.map(part => buildGeminiMessage(part, getContent));
if (systemInstruction) {
messages.unshift({
role: "user",
parts: [{text: systemInstruction}],
});
}
return {chatMessages: messages, imageCount};
}
return {chatMessages: [], imageCount: -1};
} }
export async function transcribeAudioIfNeeded(provider: AiProvider, userId: number | undefined, downloads: AiDownloadedFile[], message: TelegramStreamMessage, signal: AbortSignal): Promise<string> { export async function transcribeAudioIfNeeded(provider: AiProvider, userId: number | undefined, downloads: AiDownloadedFile[], message: TelegramStreamMessage, signal: AbortSignal): Promise<string> {
@@ -838,7 +1021,7 @@ export async function transcribeAudioIfNeeded(provider: AiProvider, userId: numb
}); });
return transcript; return transcript;
} catch (e) { } catch (e) {
aiLog("error", "speech_to_text.failed", {duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)}); aiLog("error", "speech_to_text.failed", {duration: aiLogDuration(startedAt), error: e});
throw e; throw e;
} }
} }
@@ -855,11 +1038,20 @@ export function stripAudioFromRunnerMessages(parts: AiChatMessage[]): void {
if ("videoNotes" in part) { if ("videoNotes" in part) {
delete part.videoNotes; delete part.videoNotes;
} }
if ("parts" in part && Array.isArray(part.parts)) {
part.parts = part.parts.filter(geminiPart => {
if (!("inlineData" in geminiPart)) return true;
const mimeType = geminiPart.inlineData.mimeType.toLowerCase();
return !mimeType.startsWith("audio/") && !mimeType.startsWith("video/");
});
}
} }
} }
export function appendTranscriptToChatMessages( export function appendTranscriptToChatMessages(
chatMessages: AiChatMessage[], chatMessages: AiChatMessage[],
provider: AiProvider,
transcript: string, transcript: string,
): void { ): void {
const lastUser = [...chatMessages].reverse().find(message => "role" in message && message.role === "user"); const lastUser = [...chatMessages].reverse().find(message => "role" in message && message.role === "user");
@@ -868,6 +1060,11 @@ export function appendTranscriptToChatMessages(
const text = transcript.trim(); const text = transcript.trim();
if (!text) return; if (!text) return;
if (provider === AiProvider.GEMINI && "parts" in lastUser && Array.isArray(lastUser.parts)) {
lastUser.parts.push({text});
return;
}
if (!("content" in lastUser)) return; if (!("content" in lastUser)) return;
if (typeof lastUser.content === "string") { if (typeof lastUser.content === "string") {
@@ -883,7 +1080,8 @@ export function appendTranscriptToChatMessages(
// narrows it to the Chat Completions union (`text | image_url | thinking`), // narrows it to the Chat Completions union (`text | image_url | thinking`),
// which makes comparisons with Responses parts (`input_text | input_image`) // which makes comparisons with Responses parts (`input_text | input_image`)
// look impossible even though this is a runtime mixed-provider guard. // look impossible even though this is a runtime mixed-provider guard.
const partType = (part as {type?: string}).type; const record: Record<string, unknown> = part;
const partType = record["type"];
return partType === "input_text" || partType === "input_image"; return partType === "input_text" || partType === "input_image";
}); });
@@ -906,8 +1104,8 @@ export async function deleteMistralLibrary(libraryId: string | undefined, target
await mistralAi.beta.libraries.delete({libraryId}); await mistralAi.beta.libraries.delete({libraryId});
aiLog("success", "mistral.library.delete.done", {libraryId, duration: aiLogDuration(startedAt)}); aiLog("success", "mistral.library.delete.done", {libraryId, duration: aiLogDuration(startedAt)});
} catch (e) { } catch (e) {
aiLog("error", "mistral.library.delete.failed", {libraryId, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)}); aiLog("error", "mistral.library.delete.failed", {libraryId, duration: aiLogDuration(startedAt), error: e});
logError(e instanceof Error ? e : String(e)); logError(e);
} }
} }
@@ -1058,7 +1256,7 @@ export async function prepareMistralDocuments(downloads: AiDownloadedFile[], mes
aiLog("error", "mistral.documents.prepare.failed", { aiLog("error", "mistral.documents.prepare.failed", {
libraryId, libraryId,
duration: aiLogDuration(startedAt), duration: aiLogDuration(startedAt),
error: e instanceof Error ? e : String(e), error: e,
}); });
await deleteMistralLibrary(libraryId, target); await deleteMistralLibrary(libraryId, target);
throw e; throw e;
@@ -1066,7 +1264,6 @@ export async function prepareMistralDocuments(downloads: AiDownloadedFile[], mes
} }
export async function executeTool( export async function executeTool(
userId: number | undefined | null,
toolCall: ToolCallData, toolCall: ToolCallData,
message: TelegramStreamMessage, message: TelegramStreamMessage,
context: ToolRuntimeContext, context: ToolRuntimeContext,
@@ -1081,7 +1278,7 @@ export async function executeTool(
await message.flush(); await message.flush();
const parsedArgs = parseToolArgumentsObject(toolCall.argumentsText); const parsedArgs = parseToolArgumentsObject(toolCall.argumentsText);
if (!parsedArgs.ok) { if (parsedArgs.ok === false) {
const result = toolFailureResult("invalid_arguments", parsedArgs.message, { const result = toolFailureResult("invalid_arguments", parsedArgs.message, {
raw: parsedArgs.raw.slice(0, 4000), raw: parsedArgs.raw.slice(0, 4000),
}); });
@@ -1096,7 +1293,7 @@ export async function executeTool(
} }
try { try {
const rawResult = await executeToolCall(userId, toolCall.name, parsedArgs.args, context); const rawResult = await executeToolCall(toolCall.name, parsedArgs.args, context);
const result = stringifyToolExecutionResult(rawResult); const result = stringifyToolExecutionResult(rawResult);
await sendToolArtifacts(toolCall, result, message); await sendToolArtifacts(toolCall, result, message);
@@ -1109,47 +1306,35 @@ export async function executeTool(
return result; return result;
} catch (error) { } catch (error) {
if (isAbortError(error instanceof Error ? error : String(error))) { if (isAbortError(error)) {
throw error; throw error;
} }
const result = toolFailureResult("execution_failed", errorMessage(error instanceof Error ? error : String(error))); const result = toolFailureResult("execution_failed", errorMessage(error));
aiLog("error", "tool.failed.returned_to_model", { aiLog("error", "tool.failed.returned_to_model", {
name: toolCall.name, name: toolCall.name,
duration: aiLogDuration(startedAt), duration: aiLogDuration(startedAt),
error: error instanceof Error ? error : String(error), error,
}); });
return result; return result;
} }
} }
export function toolResourceKeys(toolCall: ToolCallData, userId?: number | undefined | null): string[] { export function toolResourceKeys(toolCall: ToolCallData): string[] {
const args = safeJsonParseObject(toolCall.argumentsText); const args = safeJsonParseObject(toolCall.argumentsText);
const pathValue = typeof args.path === "string" ? args.path : undefined; const pathValue = typeof args.path === "string" ? args.path : undefined;
const sourcePath = typeof args.sourcePath === "string" ? args.sourcePath : undefined; const sourcePath = typeof args.sourcePath === "string" ? args.sourcePath : undefined;
const targetPath = typeof args.targetPath === "string" ? args.targetPath : undefined; const targetPath = typeof args.targetPath === "string" ? args.targetPath : undefined;
const memoryScope = toolCall.name.endsWith("_user_info") ? "user"
: toolCall.name.endsWith("_system_info") ? "system"
: undefined;
switch (toolCall.name) { switch (toolCall.name) {
case "read_user_info":
case "read_system_info":
case "get_datetime": case "get_datetime":
case "web_search": case "web_search":
case "get_weather": case "get_weather":
case "read_file": case "read_file":
case "list_directory": case "list_directory":
return []; return [];
case "add_user_info":
case "add_system_info":
case "remove_user_info":
case "remove_system_info":
case "replace_user_info":
case "replace_system_info":
return userId && memoryScope ? [`memory:${userId}:${memoryScope}`] : [];
case "create_file": case "create_file":
case "create_directory": case "create_directory":
case "update_file": case "update_file":
@@ -1177,18 +1362,16 @@ export async function runWithToolLocks<T>(keys: string[], task: () => Promise<T>
} }
export async function executeScheduledTool( export async function executeScheduledTool(
userId: number | undefined | null,
toolCall: ToolCallData, toolCall: ToolCallData,
message: TelegramStreamMessage, message: TelegramStreamMessage,
context: ToolRuntimeContext, context: ToolRuntimeContext,
): Promise<string> { ): Promise<string> {
const keys = toolResourceKeys(toolCall, userId); const keys = toolResourceKeys(toolCall);
if (!keys.length) return executeTool(userId, toolCall, message, context); if (!keys.length) return executeTool(toolCall, message, context);
return runWithToolLocks(keys, () => executeTool(userId, toolCall, message, context)); return runWithToolLocks(keys, () => executeTool(toolCall, message, context));
} }
export async function executeToolBatch( export async function executeToolBatch(
userId: number | undefined | null,
toolCalls: ToolCallData[], toolCalls: ToolCallData[],
message: TelegramStreamMessage, message: TelegramStreamMessage,
context: ToolRuntimeContext, context: ToolRuntimeContext,
@@ -1229,7 +1412,7 @@ export async function executeToolBatch(
message.setStatus(Environment.getUseToolText(statusCalls)); message.setStatus(Environment.getUseToolText(statusCalls));
await message.flush(); await message.flush();
const resultText = await executeScheduledTool(userId, toolCall, message, context); const resultText = await executeScheduledTool(toolCall, message, context);
memory.set(signature, { memory.set(signature, {
count: (previous?.count ?? 0) + 1, count: (previous?.count ?? 0) + 1,
@@ -1263,33 +1446,6 @@ export async function executeToolBatch(
message.setStatus(Environment.getUseToolText(statusCalls)); message.setStatus(Environment.getUseToolText(statusCalls));
await message.flush(); await message.flush();
const finishedAt = new Date().toISOString();
await Promise.all(results.map(async (resultText, index) => {
const toolCall = toolCalls[index];
if (!toolCall) return;
message.recordToolExecution({
toolName: toolCall.name,
callId: toolCall.id,
argumentsText: toolCall.argumentsText,
resultChars: resultText.length,
startedAt: new Date(startedAt).toISOString(),
finishedAt,
});
try {
const attachment = await persistToolResultArtifactAttachment({
toolCall,
resultText,
chatId: message.sourceChatId(),
messageId: message.sourceMessageId(),
});
await message.storeInternalAttachment(attachment);
} catch (error) {
logError(error instanceof Error ? error : String(error));
}
}));
aiLog("success", "tool.batch.done", { aiLog("success", "tool.batch.done", {
count: toolCalls.length, count: toolCalls.length,
uniqueCount: statusCalls.length, uniqueCount: statusCalls.length,
@@ -1301,7 +1457,7 @@ export async function executeToolBatch(
aiLog("error", "tool.batch.failed", { aiLog("error", "tool.batch.failed", {
count: toolCalls.length, count: toolCalls.length,
duration: aiLogDuration(startedAt), duration: aiLogDuration(startedAt),
error: e instanceof Error ? e : String(e), error: e,
}); });
throw e; throw e;
@@ -1318,7 +1474,7 @@ export function appendOllamaToolResults(messages: ChatMessage[], calls: ToolCall
} }
} }
export function stringifyToolExecutionResult(result: BoundaryValue): string { export function stringifyToolExecutionResult(result: unknown): string {
if (typeof result === "string") return result; if (typeof result === "string") return result;
const json = JSON.stringify(toJsonValue(result) ?? String(result)); const json = JSON.stringify(toJsonValue(result) ?? String(result));
return json ?? String(result); return json ?? String(result);
@@ -1326,7 +1482,7 @@ export function stringifyToolExecutionResult(result: BoundaryValue): string {
export type ToolExecutionMemory = Map<string, { count: number; result: string }>; export type ToolExecutionMemory = Map<string, { count: number; result: string }>;
export function stableJsonStringify(value: BoundaryValue): string { export function stableJsonStringify(value: unknown): string {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return `[${value.map(stableJsonStringify).join(",")}]`; return `[${value.map(stableJsonStringify).join(",")}]`;
} }
@@ -1441,14 +1597,14 @@ export type NormalizedRouterPlan = {
m: string; // Missing m: string; // Missing
}; };
export function toolSchemaName(tool: BoundaryValue): string | undefined { export function toolSchemaName(tool: unknown): string | undefined {
if (!isRecord(tool)) return undefined; if (!isRecord(tool)) return undefined;
const fn = isRecord(tool.function) ? tool.function : undefined; const fn = isRecord(tool.function) ? tool.function : undefined;
const directName = fn?.name ?? tool.name ?? (typeof tool.type === "string" && tool.type !== "function" ? tool.type : undefined); const directName = fn?.name ?? tool.name;
return asOptionalString(directName); return asOptionalString(directName);
} }
export function toolSchemaNames(tool: BoundaryValue): string[] { export function toolSchemaNames(tool: unknown): string[] {
if (!isRecord(tool)) return []; if (!isRecord(tool)) return [];
if (Array.isArray(tool.functionDeclarations)) { if (Array.isArray(tool.functionDeclarations)) {
@@ -1461,18 +1617,13 @@ export function toolSchemaNames(tool: BoundaryValue): string[] {
return name ? [name] : []; return name ? [name] : [];
} }
export function allToolSchemaNames(tools: readonly BoundaryValue[]): string[] { export function allToolSchemaNames(tools: readonly unknown[]): string[] {
return [...new Set(tools.flatMap(toolSchemaNames))]; return [...new Set(tools.flatMap(toolSchemaNames))];
} }
export function getOpenAIResponsesToolsWithImage( export function getOpenAIResponsesToolsWithImage(config: RuntimeConfigSnapshot): Array<OpenAiResponseTool | LooseRecord> {
config: RuntimeConfigSnapshot, return [
forCreator?: boolean, ...getOpenAIResponsesTools(),
vectorStoreIds: string[] = [],
): Array<OpenAiResponseTool | LooseRecord> {
const tools: Array<OpenAiResponseTool | LooseRecord> = [
...getOpenAIResponsesTools(forCreator),
getOpenAICodeInterpreterTool(),
{ {
type: "image_generation", type: "image_generation",
model: config.openAiImageTarget.model, model: config.openAiImageTarget.model,
@@ -1481,17 +1632,7 @@ export function getOpenAIResponsesToolsWithImage(
output_format: "png", output_format: "png",
partial_images: OPENAI_IMAGE_PARTIALS, partial_images: OPENAI_IMAGE_PARTIALS,
}, },
{type: "web_search"},
]; ];
if (vectorStoreIds.length) {
tools.unshift({
type: "file_search",
vector_store_ids: vectorStoreIds,
});
}
return tools;
} }
export function collectOpenAiResponseText(response: OpenAiResponseLike): string { export function collectOpenAiResponseText(response: OpenAiResponseLike): string {
@@ -1514,42 +1655,17 @@ export function collectOpenAiResponseFunctionCalls(response: OpenAiResponseLike)
})); }));
} }
export type OpenAiCodeInterpreterCall = {
id: string;
code: string | null;
containerId: string;
status: string;
outputs: Array<{ type?: "logs" | "image"; logs?: string; url?: string }>;
};
export function collectOpenAiResponseCodeInterpreterCalls(response: OpenAiResponseLike): OpenAiCodeInterpreterCall[] {
return (response.output ?? [])
.filter(item => item.type === "code_interpreter_call" && item.id && item.container_id)
.map(item => ({
id: item.id!,
code: item.code ?? null,
containerId: item.container_id!,
status: item.status ?? "unrecognized",
outputs: Array.isArray(item.outputs) ? item.outputs : [],
}));
}
export function collectOpenAiResponseImages(response: OpenAiResponseLike): string[] { export function collectOpenAiResponseImages(response: OpenAiResponseLike): string[] {
return (response.output ?? []) return (response.output ?? [])
.filter(item => item.type === "image_generation_call" && typeof item.result === "string") .filter(item => item.type === "image_generation_call" && typeof item.result === "string")
.map(item => item.result!); .map(item => item.result!);
} }
export function writeOpenAiGeneratedImage(sourceMessage: Message, b64: string, label: string): { export function writeOpenAiGeneratedImage(sourceMessage: Message, b64: string, label: string): Buffer {
buffer: Buffer; const imageBuffer = Buffer.from(b64, "base64");
cachePath: string;
fileName: string;
} {
const buffer = Buffer.from(b64, "base64");
const fileName = `${sourceMessage.chat.id}_${sourceMessage.message_id}_${Date.now()}_${label}.png`; const fileName = `${sourceMessage.chat.id}_${sourceMessage.message_id}_${Date.now()}_${label}.png`;
const cachePath = path.join(photoGenDir(), fileName); fs.writeFileSync(path.join(photoGenDir, fileName), imageBuffer);
fs.writeFileSync(cachePath, buffer); return imageBuffer;
return {buffer, cachePath, fileName};
} }
export async function showOpenAiGeneratedImage( export async function showOpenAiGeneratedImage(
@@ -1560,21 +1676,14 @@ export async function showOpenAiGeneratedImage(
status: string, status: string,
final: boolean, final: boolean,
): Promise<void> { ): Promise<void> {
const image = writeOpenAiGeneratedImage(sourceMessage, b64, label); const imageBuffer = writeOpenAiGeneratedImage(sourceMessage, b64, label);
const attachment: StoredAttachment = {
kind: "image",
fileId: image.cachePath,
fileName: image.fileName,
mimeType: "image/png",
cachePath: image.cachePath,
};
if (final && !streamMessage.getText().trim()) { if (final && !streamMessage.getText().trim()) {
streamMessage.replaceText(status); streamMessage.replaceText(status);
streamMessage.clearStatus(); streamMessage.clearStatus();
} else { } else {
streamMessage.setStatus(status); streamMessage.setStatus(status);
} }
await streamMessage.showImage(image.buffer, attachment); await streamMessage.showImage(imageBuffer);
} }
export function openAiResponseItemCallId(item: OpenAiResponseOutputItem & { id?: string }): string { export function openAiResponseItemCallId(item: OpenAiResponseOutputItem & { id?: string }): string {
+191 -214
View File
@@ -1,248 +1,225 @@
import type {ChatCompletionCreateParamsNonStreaming, ChatCompletionMessageParam} from "openai/resources/chat/completions"; import {Tool} from "ollama";
import {ChatRequest} from "ollama"; import {AiRuntimeTarget, createOllamaClient} from "./ai-runtime-target";
import {BoundaryValue} from "../common/boundary-types.js"; import {aiLog, aiLogDuration, aiLogProviderTarget} from "../logging/ai-logger";
import {ToolRankerFallbackPolicy} from "../common/policies.js"; import {allToolSchemaNames, isRecord, JsonObject, RuntimeConfigSnapshot, safeJsonParseObject, toolSchemaNames} from "./unified-ai-runner.shared";
import {AiProvider} from "../model/ai-provider.js";
import {createMistralClient, createOllamaClient, createOpenAiClient, sameRuntimeEndpoint} from "./ai-runtime-target.js";
import {aiLog, aiLogDuration, aiLogProviderTarget} from "../logging/ai-logger.js";
import {providerChatTarget, RuntimeConfigSnapshot} from "./unified-ai-runner.shared.js";
import {
buildRankerContext,
buildRankerTarget,
buildToolRankerPrompt,
filterRankedTools,
ToolRankerSelection,
} from "./tool-ranker-pipeline.js";
import {allToolSchemaNames} from "./unified-ai-runner.shared.js";
import {sanitizeToolRankerResult} from "./tool-ranker-metadata.js";
import {resolveToolRankerFallbackSelection} from "./tool-ranker-fallback.js";
export class ToolRanker { type RankedToolStep = {
constructor(private readonly config: RuntimeConfigSnapshot) { t: string | string[];
} h?: string;
from?: string;
};
type RankedToolPlan = {
s?: RankedToolStep[];
m?: string;
};
export type ToolRankerSelection = {
tools: Tool[];
selectedNames: string[];
missing: string;
raw: string;
usedRanker: boolean;
};
export class OllamaToolRanker {
constructor(private readonly config: RuntimeConfigSnapshot) {}
async selectTools(args: { async selectTools(args: {
provider: AiProvider;
userQuery: string; userQuery: string;
availableTools: readonly BoundaryValue[]; availableTools: Tool[];
round: number; round: number;
signal: AbortSignal; signal: AbortSignal;
messages?: readonly { role?: string; content?: string | readonly { text?: string }[] }[];
runRanker?: (
provider: AiProvider,
target: NonNullable<ReturnType<typeof buildRankerTarget>>,
prompt: string,
userQuery: string,
) => Promise<string>;
}): Promise<ToolRankerSelection> { }): Promise<ToolRankerSelection> {
const {availableTools, provider, round, signal, userQuery} = args; const {availableTools, round, signal, userQuery} = args;
const runRanker = args.runRanker ?? this.runRanker.bind(this); const target = this.config.ollamaToolRankerTarget;
const availableNames = allToolSchemaNames(availableTools);
const fallbackPolicy = this.config.toolRankerFallbackPolicy;
const configuredTarget = buildRankerTarget(this.config, provider);
const mainModelTarget = providerChatTarget(provider, this.config);
if (!availableTools.length) { if (!availableTools.length) {
return {toolNames: [], usedRanker: false}; return {tools: [], selectedNames: [], missing: "", raw: "", usedRanker: false};
} }
const target = configuredTarget ?? (fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL ? mainModelTarget : undefined); // Ranker disabled/unconfigured: keep old behavior and expose all allowed tools.
if (!target?.model) {
if (!target) { return {
return resolveToolRankerFallbackSelection({ tools: availableTools,
fallbackPolicy, selectedNames: allToolSchemaNames(availableTools),
availableToolNames: availableNames, missing: "",
}); raw: "",
usedRanker: false,
};
} }
const startedAt = Date.now(); const startedAt = Date.now();
const ranker = buildToolRankerPrompt(buildRankerContext(this.config, provider, target, round, userQuery, availableTools)); const availableNames = new Set(allToolSchemaNames(availableTools));
const prompt = this.config.rankerToolPrompt?.trim() || DEFAULT_TOOL_RANKER_PROMPT;
const toolsForPrompt = availableTools.map(tool => ({
names: toolSchemaNames(tool),
schema: tool,
}));
aiLog("debug", "tool_ranker.start", { aiLog("debug", "ollama.tool_ranker.start", {
provider,
round, round,
target: aiLogProviderTarget(target), target: aiLogProviderTarget(target),
queryChars: userQuery.length, queryChars: userQuery.length,
availableTools: availableNames, availableTools: [...availableNames],
fallbackPolicy,
usedMainModelFallback: !configuredTarget && fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL,
}); });
try { try {
if (signal.aborted) throw new Error("Aborted"); const ollama = createOllamaClient(target as AiRuntimeTarget);
const raw = await runRanker(provider, target, ranker.prompt, userQuery); const response = await ollama.chat({
if (signal.aborted) throw new Error("Aborted");
const selectedNames = sanitizeToolRankerResult({
raw,
availableToolNames: availableNames,
});
const filtered = filterRankedTools(availableTools, selectedNames);
const toolNames = allToolSchemaNames(filtered);
aiLog("debug", "tool_ranker.done", {
provider,
round,
duration: aiLogDuration(startedAt),
selectedNames,
selectedCount: toolNames.length,
rawPreview: raw.slice(0, 800),
});
return {toolNames, usedRanker: true};
} catch (error) {
if (error instanceof Error && error.message.includes("Aborted")) throw error;
let failureMessage = error instanceof Error ? error.message : String(error);
const canRetryOnMainModel = fallbackPolicy === ToolRankerFallbackPolicy.MAIN_MODEL
&& (
target.model !== mainModelTarget.model
|| !sameRuntimeEndpoint(target, mainModelTarget)
);
if (canRetryOnMainModel) {
try {
aiLog("warn", "tool_ranker.failed.retry_main_model", {
provider,
round,
target: aiLogProviderTarget(target),
fallbackTarget: aiLogProviderTarget(mainModelTarget),
duration: aiLogDuration(startedAt),
errorSummary: failureMessage,
});
const fallbackRanker = buildToolRankerPrompt(
buildRankerContext(this.config, provider, mainModelTarget, round, userQuery, availableTools),
);
const raw = await runRanker(provider, mainModelTarget, fallbackRanker.prompt, userQuery);
const selectedNames = sanitizeToolRankerResult({
raw,
availableToolNames: availableNames,
});
const filtered = filterRankedTools(availableTools, selectedNames);
const toolNames = allToolSchemaNames(filtered);
aiLog("debug", "tool_ranker.done", {
provider,
round,
duration: aiLogDuration(startedAt),
selectedNames,
selectedCount: toolNames.length,
rawPreview: raw.slice(0, 800),
fallbackUsed: true,
});
return {toolNames, usedRanker: true};
} catch (fallbackError) {
if (fallbackError instanceof Error && fallbackError.message.includes("Aborted")) throw fallbackError;
const fallbackErrorMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
aiLog("warn", "tool_ranker.failed.main_model_fallback_failed", {
provider,
round,
target: aiLogProviderTarget(target),
fallbackTarget: aiLogProviderTarget(mainModelTarget),
duration: aiLogDuration(startedAt),
errorSummary: fallbackErrorMessage,
});
failureMessage = fallbackErrorMessage;
}
}
aiLog("warn", "tool_ranker.failed.fallback_all_allowed", {
provider,
round,
target: aiLogProviderTarget(target),
fallbackPolicy,
duration: aiLogDuration(startedAt),
errorSummary: failureMessage,
});
return resolveToolRankerFallbackSelection({
fallbackPolicy,
availableToolNames: availableNames,
});
}
}
private async runRanker(
provider: AiProvider,
target: NonNullable<ReturnType<typeof buildRankerTarget>>,
prompt: string,
userQuery: string,
): Promise<string> {
switch (provider) {
case AiProvider.OLLAMA: {
const ollama = createOllamaClient(target);
const request = {
model: target.model, model: target.model,
messages: [ messages: [
{role: "system", content: prompt}, {role: "system", content: prompt},
{role: "user", content: userQuery}, {
role: "user",
content: JSON.stringify({
q: userQuery,
tools: toolsForPrompt,
}),
},
], ],
stream: false as const, stream: false,
think: false,
format: {
type: "object",
properties: {
toolNames: {
type: "array",
items: {type: "string"},
},
},
required: ["toolNames"],
additionalProperties: false,
},
options: { options: {
temperature: 0, temperature: 0,
top_p: 0.8,
top_k: 20,
repeat_penalty: 1.05,
num_ctx: 8192, num_ctx: 8192,
num_predict: 256,
}, },
} satisfies ChatRequest & { stream: false }; });
const response = await ollama.chat(request); if (signal.aborted) throw new Error("Aborted");
return response.message?.content?.trim() ?? "";
} const raw = response.message?.content?.trim() ?? "";
case AiProvider.MISTRAL: { const plan = parseToolRankerPlan(raw);
const mistral = createMistralClient(target); const selectedNames = normalizeToolRankerNames(plan, availableNames);
const request: Parameters<typeof mistral.chat.complete>[0] = { const selectedNameSet = new Set(selectedNames);
model: target.model, const tools = availableTools.filter(tool => toolSchemaNames(tool).some(name => selectedNameSet.has(name)));
messages: [ const missing = typeof plan?.m === "string" ? plan.m.trim() : "";
{role: "system", content: prompt},
{role: "user", content: userQuery}, aiLog("debug", "ollama.tool_ranker.done", {
], round,
temperature: 0, duration: aiLogDuration(startedAt),
selectedNames,
selectedCount: tools.length,
missing,
rawPreview: raw.slice(0, 800),
});
// Important: if the plan is valid and empty, use NO tools. Do not fall back to all tools.
return {tools, selectedNames, missing, raw, usedRanker: true};
} catch (error) {
if (String(error).includes("Aborted")) throw error;
aiLog("warn", "ollama.tool_ranker.failed.fallback_all_allowed", {
round,
target: aiLogProviderTarget(target),
duration: aiLogDuration(startedAt),
error,
});
// Ranker transport/model failure is different from "ranker returned empty plan".
// In that case, preserve availability rather than silently disabling tools.
return {
tools: availableTools,
selectedNames: allToolSchemaNames(availableTools),
missing: "",
raw: "",
usedRanker: false,
}; };
const response = await mistral.chat.complete(request);
const message = response.choices?.[0]?.message;
return typeof message?.content === "string" ? message.content.trim() : "";
}
case AiProvider.OPENAI: {
const openAi = createOpenAiClient(target);
const messages = [
{role: "system", content: prompt},
{role: "user", content: userQuery},
] satisfies ChatCompletionMessageParam[];
// OpenAI-compatible servers often reject `response_format`, so keep JSON mode
// only for official OpenAI endpoints.
const request: ChatCompletionCreateParamsNonStreaming = {
model: target.model,
messages,
};
if (!target.baseUrl) {
// gpt-5 family ranker targets reject temperature=0; use the model default instead.
request.response_format = {type: "json_object"};
}
const response = await openAi.chat.completions.create(request);
return response.choices[0]?.message?.content?.trim() ?? "";
}
} }
} }
} }
export function latestUserTextFromOllamaMessages(messages: readonly { role?: string; content?: unknown }[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message?.role !== "user") continue;
if (typeof message.content === "string") return message.content;
if (Array.isArray(message.content)) {
return message.content
.map(part => isRecord(part) && typeof part.text === "string" ? part.text : "")
.filter(Boolean)
.join("\n");
}
}
return "";
}
export function looksLikeToolRankerJson(text: string): boolean {
const parsed = safeJsonParseObject(extractJsonObjectText(text) ?? text);
return Array.isArray(parsed.s) && typeof parsed.m === "string";
}
function parseToolRankerPlan(raw: string): RankedToolPlan | undefined {
const jsonText = extractJsonObjectText(raw);
if (!jsonText) return undefined;
const parsed = safeJsonParseObject(jsonText) as JsonObject;
if (!Array.isArray(parsed.s)) return undefined;
return parsed as RankedToolPlan;
}
function normalizeToolRankerNames(plan: RankedToolPlan | undefined, availableNames: Set<string>): string[] {
if (!plan?.s?.length) return [];
const result: string[] = [];
for (const step of plan.s) {
const rawNames = Array.isArray(step.t) ? step.t : [step.t];
for (const rawName of rawNames) {
if (typeof rawName !== "string") continue;
const name = rawName.trim();
if (availableNames.has(name) && !result.includes(name)) {
result.push(name);
}
}
}
return result;
}
function extractJsonObjectText(raw: string): string | undefined {
const text = raw.trim()
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
const start = text.indexOf("{");
if (start === -1) return undefined;
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === "{") depth++;
if (ch === "}") depth--;
if (depth === 0) {
return text.slice(start, i + 1);
}
}
return undefined;
}
const DEFAULT_TOOL_RANKER_PROMPT = `You are a tool router. Return strict compact JSON only.
Schema: {"s":[{"t":"tool_name","h":"short input hint","from":"previous_tool.output_or_empty"}],"m":""}
Use tools only when they are needed. If no tool is needed, return {"s":[],"m":""}.
Never answer the user. Never explain. Never use markdown.`;
+155 -140
View File
@@ -5,37 +5,24 @@ import {ifTrue, logError, replyToMessage} from "../util/utils";
import {createAiCancelRequest, finishAiRequest, setAiCancelMessageId} from "./cancel-registry"; import {createAiCancelRequest, finishAiRequest, setAiCancelMessageId} from "./cancel-registry";
import {TelegramStreamMessage} from "./telegram-stream-message"; import {TelegramStreamMessage} from "./telegram-stream-message";
import {AiDownloadedFile, attachmentsToDownloadedFiles, cleanupDownloads} from "./telegram-attachments"; import {AiDownloadedFile, attachmentsToDownloadedFiles, cleanupDownloads} from "./telegram-attachments";
import {ChatMessage} from "./chat-messages-types";
import {aiProviderRequestQueue} from "./provider-request-queue"; import {aiProviderRequestQueue} from "./provider-request-queue";
import { import {prepareOllamaDocumentRag} from "./ollama-rag";
AI_VOICE_MODE_TRANSCRIPT, import {AI_VOICE_MODE_TRANSCRIPT, DEFAULT_AI_RESPONSE_LANGUAGE, resolveAiContextSizeForUser, resolveAiResponseLanguageForUser, resolveAiVoiceModeForUser} from "../common/user-ai-settings";
resolveAiContextSizeForUser, import {isTranscribableAudioDownload} from "./speech-to-text";
resolveAiImageOutputModeForUser, import {OpenAIChatMessage} from "./openai-chat-message";
resolveAiResponseLanguageForUser, import {MistralChatMessage} from "./mistral-chat-message";
resolveAiVoiceModeForUser import {OllamaChatMessage} from "./ollama-chat-message";
} from "../common/user-ai-settings"; import {GeminiMessage} from "./gemini-chat-message";
import {buildAiRegenerateCallbackData} from "./regenerate-callback"; import {buildAiRegenerateCallbackData} from "./regenerate-callback";
import {createOllamaClient, getGeminiApiMode} from "./ai-runtime-target";
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget} from "../logging/ai-logger"; import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget} from "../logging/ai-logger";
import { import {runOpenAi, runOpenAiCompatibleChat} from "./unified-ai-runner.openai";
AI_REQUEST_TIMEOUT_MS, import {runOllama} from "./unified-ai-runner.ollama";
collectCachedMessageAttachments, import {runMistral} from "./unified-ai-runner.mistral";
collectRequestedAttachmentKinds, import {runGemini} from "./unified-ai-runner.gemini";
hasAudioAttachmentKind, import {AI_REQUEST_TIMEOUT_MS, TELEGRAM_LIMIT, RuntimeConfigSnapshot, UnifiedRunOptions, appendTranscriptToChatMessages, collectCachedMessageAttachments, collectRequestedAttachmentKinds, collectTextMessages, deleteMistralLibrary, hasAudioAttachmentKind, initialStatus, isAbortError, prepareMistralDocuments, providerName, rejectUnsupportedAttachments, resolveAiRequestQueueTarget, snapshotModel, snapshotRuntimeConfig, stripAudioFromRunnerMessages, toolRuntimeContextFromDownloads, transcribeAudioIfNeeded} from "./unified-ai-runner.shared";
isAbortError,
providerName,
rejectUnsupportedAttachments,
resolveAiRequestQueueTarget,
RuntimeConfigSnapshot,
snapshotModel,
snapshotRuntimeConfig,
UnifiedRunOptions
} from "./unified-ai-runner.shared";
import {prepareUnifiedAiRequestPipeline} from "./unified-ai-request-pipeline";
import {persistErrorArtifactAttachment} from "./final-response-artifact-store";
import {runUnifiedAiResponsePipeline} from "./unified-ai-response-pipeline";
import {AiRequestStore} from "../common/ai-request-store";
import type {StoredAiRequestStatus} from "../model/stored-ai-request";
import {recordAiRequestFinish, recordAiRequestStart} from "../common/ai-observability.js";
export type {ToolCallData} from "./unified-ai-runner.shared"; export type {ToolCallData} from "./unified-ai-runner.shared";
export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared"; export {snapshotModel, providerTargets, ollamaModelNames} from "./unified-ai-runner.shared";
@@ -46,11 +33,9 @@ async function executeUnifiedAiRequest(
downloads: AiDownloadedFile[], downloads: AiDownloadedFile[],
controller: AbortController, controller: AbortController,
streamMessage: TelegramStreamMessage, streamMessage: TelegramStreamMessage,
): Promise<void> { ): Promise<{ mistralLibraryId?: string }> {
const requestStartedAt = Date.now(); const requestStartedAt = Date.now();
let preparedRequest: Awaited<ReturnType<typeof prepareUnifiedAiRequestPipeline>> | undefined;
aiLog("info", "request.execute.start", { aiLog("info", "request.execute.start", {
requestId: options.requestId,
provider: providerName(options.provider), provider: providerName(options.provider),
stream: options.stream ?? true, stream: options.stream ?? true,
think: options.think, think: options.think,
@@ -66,48 +51,138 @@ async function executeUnifiedAiRequest(
})), })),
}); });
preparedRequest = await prepareUnifiedAiRequestPipeline({ const {
options, chatMessages,
config, imageCount
} = await collectTextMessages(
options.msg,
options.text,
options.provider,
downloads, downloads,
streamMessage, config,
controller, options.responseLanguage ?? DEFAULT_AI_RESPONSE_LANGUAGE,
}); );
if (preparedRequest.finishAfterTranscript) return; const firstRoundStatus = initialStatus(downloads, imageCount);
const toolContext = toolRuntimeContextFromDownloads(downloads);
aiLog("debug", "request.messages.collected", { aiLog("debug", "request.messages.collected", {
requestId: options.requestId,
provider: providerName(options.provider), provider: providerName(options.provider),
chatMessages: preparedRequest.chatMessages.length, chatMessages: chatMessages.length,
imageCount: preparedRequest.imageCount, imageCount,
firstRoundStatus: preparedRequest.firstRoundStatus, firstRoundStatus,
hasToolInputFiles: !!preparedRequest.toolContext.pythonInputFiles?.length, hasToolInputFiles: !!toolContext.pythonInputFiles?.length,
}); });
streamMessage.setStatus(firstRoundStatus);
await streamMessage.flush();
const hasDocument = downloads.some(d => d.kind === "document");
if (hasDocument && options.provider !== AiProvider.MISTRAL && options.provider !== AiProvider.OLLAMA) {
aiLog("warn", "request.documents.unsupported_provider", {provider: providerName(options.provider)});
throw new Error(Environment.documentsUnifiedRunnerUnsupportedText);
}
let mistralLibraryId: string | undefined;
const transcript = await transcribeAudioIfNeeded(options.provider, options.msg.from?.id, downloads, streamMessage, controller.signal).catch(e => {
if (downloads.some(isTranscribableAudioDownload)) throw e;
return "";
});
if (transcript.trim()) {
if (options.voiceMode === AI_VOICE_MODE_TRANSCRIPT) {
// TODO: 12.05.2026: extract to string
streamMessage.replaceText(`[Расшифровка]\n${transcript.trim()}`);
await streamMessage.finish();
return {mistralLibraryId};
}
appendTranscriptToChatMessages(chatMessages, options.provider, transcript);
stripAudioFromRunnerMessages(chatMessages);
aiLog("debug", "request.transcript.appended", {
provider: providerName(options.provider),
transcriptChars: transcript.length,
chatMessages: chatMessages.length,
});
}
try { try {
await runUnifiedAiResponsePipeline({ const preparedMistral = options.provider === AiProvider.MISTRAL
options, ? await prepareMistralDocuments(downloads, chatMessages as MistralChatMessage[], streamMessage, config.mistralChatTarget, controller.signal)
config, : {documents: []};
const documents = preparedMistral.documents;
mistralLibraryId = preparedMistral.libraryId;
if (options.provider === AiProvider.OLLAMA) {
await prepareOllamaDocumentRag({
downloads, downloads,
prepared: preparedRequest, messages: chatMessages as OllamaChatMessage[],
streamMessage, userQuery: options.text,
controller, message: streamMessage,
config: {
embeddingModel: config.ollamaDocumentsTarget.model,
embeddingClient: createOllamaClient(config.ollamaDocumentsTarget),
chunkSize: config.ollamaRagChunkSize,
chunkOverlap: config.ollamaRagChunkOverlap,
topK: config.ollamaRagTopK,
maxContextChars: config.ollamaRagMaxContextChars,
minScore: config.ollamaRagMinScore,
maxArchiveFiles: config.ollamaRagMaxArchiveFiles,
maxArchiveBytes: config.ollamaRagMaxArchiveBytes,
maxArchiveDepth: config.ollamaRagMaxArchiveDepth,
},
}); });
}
aiLog("info", "request.provider.dispatch", {provider: providerName(options.provider)});
switch (options.provider) {
case AiProvider.OPENAI:
await runOpenAi(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, options.msg, config, toolContext);
break;
case AiProvider.OLLAMA:
const currentModel = config.ollamaChatTarget.model;
if (currentModel?.includes("gpt-oss")) {
if (options.think) {
options.think = "high";
}
}
await runOllama(options.msg, chatMessages as ChatMessage[], streamMessage, controller.signal, ifTrue(options.stream), options.think ?? false, firstRoundStatus, config, toolContext, options.contextSize);
break;
case AiProvider.MISTRAL:
await runMistral(chatMessages as MistralChatMessage[], documents, streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
break;
case AiProvider.GEMINI:
if (getGeminiApiMode(config.geminiChatTarget) === "openai") {
await runOpenAiCompatibleChat(options.msg, chatMessages as OpenAIChatMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
} else {
await runGemini(chatMessages as GeminiMessage[], streamMessage, controller.signal, options.stream ?? true, firstRoundStatus, config, toolContext);
}
break;
}
if (streamMessage.getText().length > TELEGRAM_LIMIT) {
streamMessage.replaceText(streamMessage.getText().slice(0, TELEGRAM_LIMIT - 3) + "...");
}
await streamMessage.finish();
// await sendVoiceResponseIfNeeded(options, downloads, streamMessage.getText());
aiLog("success", "request.execute.done", { aiLog("success", "request.execute.done", {
requestId: options.requestId,
provider: providerName(options.provider), provider: providerName(options.provider),
duration: aiLogDuration(requestStartedAt), duration: aiLogDuration(requestStartedAt),
responseChars: streamMessage.getText().length, responseChars: streamMessage.getText().length,
mistralLibraryId: preparedRequest?.preparedDocumentRag?.provider === AiProvider.MISTRAL ? preparedRequest.preparedDocumentRag.libraryId : undefined, mistralLibraryId,
}); });
return; return {mistralLibraryId};
} catch (e) { } catch (e) {
aiLog("error", "request.execute.failed", { aiLog("error", "request.execute.failed", {
requestId: options.requestId,
provider: providerName(options.provider), provider: providerName(options.provider),
duration: aiLogDuration(requestStartedAt), duration: aiLogDuration(requestStartedAt),
error: e instanceof Error ? e : String(e), error: e,
}); });
if (mistralLibraryId) {
await deleteMistralLibrary(mistralLibraryId, config.mistralChatTarget);
}
throw e; throw e;
} }
} }
@@ -118,11 +193,9 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
options.responseLanguage ??= await resolveAiResponseLanguageForUser(options.msg.from?.id); options.responseLanguage ??= await resolveAiResponseLanguageForUser(options.msg.from?.id);
options.contextSize ??= await resolveAiContextSizeForUser(options.msg.from?.id); options.contextSize ??= await resolveAiContextSizeForUser(options.msg.from?.id);
options.voiceMode ??= await resolveAiVoiceModeForUser(options.msg.from?.id); options.voiceMode ??= await resolveAiVoiceModeForUser(options.msg.from?.id);
const imageOutputMode = await resolveAiImageOutputModeForUser(options.msg.from?.id);
const requestedAttachmentKinds = await collectRequestedAttachmentKinds(options.msg); const requestedAttachmentKinds = await collectRequestedAttachmentKinds(options.msg);
aiLog("info", "run.start", { aiLog("info", "run.start", {
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
provider: providerName(options.provider), provider: providerName(options.provider),
model: snapshotModel(options.provider, config), model: snapshotModel(options.provider, config),
message: aiLogMessageIdentity(options.msg), message: aiLogMessageIdentity(options.msg),
@@ -139,7 +212,6 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
if (await rejectUnsupportedAttachments(options.provider, snapshotModel(options.provider, config), options.msg, config, requestedAttachmentKinds)) { if (await rejectUnsupportedAttachments(options.provider, snapshotModel(options.provider, config), options.msg, config, requestedAttachmentKinds)) {
aiLog("warn", "run.rejected.unsupported_attachment", { aiLog("warn", "run.rejected.unsupported_attachment", {
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
provider: providerName(options.provider), provider: providerName(options.provider),
requestedAttachmentKinds: [...requestedAttachmentKinds], requestedAttachmentKinds: [...requestedAttachmentKinds],
}); });
@@ -157,7 +229,6 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
text: Environment.getAttachmentMissingFromCacheText(cached.missing[0].fileName), text: Environment.getAttachmentMissingFromCacheText(cached.missing[0].fileName),
}).catch(logError); }).catch(logError);
aiLog("warn", "run.rejected.missing_attachment_cache", { aiLog("warn", "run.rejected.missing_attachment_cache", {
requestId: options.requestId ?? `pending:${options.msg.chat.id}:${options.msg.message_id}`,
missing: cached.missing.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})), missing: cached.missing.map(a => ({kind: a.kind, fileName: a.fileName, cachePath: a.cachePath})),
}); });
return; return;
@@ -165,17 +236,12 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS); const timeout = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS);
let aiRequestStatus: StoredAiRequestStatus = "running";
let aiRequestError: string | undefined;
let responseMessageId: number | undefined;
const cancel = createAiCancelRequest({ const cancel = createAiCancelRequest({
chatId: options.msg.chat.id, chatId: options.msg.chat.id,
fromId: options.msg.from?.id ?? 0, fromId: options.msg.from?.id ?? 0,
provider: providerName(options.provider), provider: providerName(options.provider),
controller controller
}); });
options.requestId ??= cancel.id;
const requestId = options.requestId;
const streamMessage = new TelegramStreamMessage( const streamMessage = new TelegramStreamMessage(
options.msg, options.msg,
cancel.id, cancel.id,
@@ -185,42 +251,17 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
: buildAiRegenerateCallbackData(options.provider, !!options.think), : buildAiRegenerateCallbackData(options.provider, !!options.think),
options.targetMessage, options.targetMessage,
options.provider, options.provider,
options.isGuestMsg, options.isGuestMsg
imageOutputMode
); );
cancel.onCancel = () => streamMessage.cancel(cancel.provider); cancel.onCancel = () => streamMessage.cancel(cancel.provider);
let mistralLibraryId: string | undefined;
const queueTarget = resolveAiRequestQueueTarget(options, config, requestedAttachmentKinds); const queueTarget = resolveAiRequestQueueTarget(options, config, requestedAttachmentKinds);
aiLog("debug", "run.queue.target", {requestId, target: aiLogProviderTarget(queueTarget), cancelId: cancel.id}); aiLog("debug", "run.queue.target", {target: aiLogProviderTarget(queueTarget), cancelId: cancel.id});
const aiRequestStartedAt = new Date().toISOString();
recordAiRequestStart();
await AiRequestStore.put({
requestId,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
fromId: options.msg.from?.id ?? 0,
provider: options.provider,
model: snapshotModel(options.provider, config),
status: "running",
startedAt: aiRequestStartedAt,
}).catch(logError);
try { try {
const queueMessage = await streamMessage.start(Environment.waitThinkText); const queueMessage = await streamMessage.start(Environment.getAiQueueText(options.provider, 0));
responseMessageId = queueMessage.message_id; setAiCancelMessageId(cancel.id, queueMessage.message_id);
await AiRequestStore.put({
requestId,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
responseMessageId,
fromId: options.msg.from?.id ?? 0,
provider: options.provider,
model: snapshotModel(options.provider, config),
status: "running",
startedAt: aiRequestStartedAt,
}).catch(logError);
setAiCancelMessageId(requestId, queueMessage.message_id);
aiLog("info", "run.queue.enter", { aiLog("info", "run.queue.enter", {
requestId,
cancelId: cancel.id, cancelId: cancel.id,
queueMessageId: queueMessage.message_id, queueMessageId: queueMessage.message_id,
target: aiLogProviderTarget(queueTarget), target: aiLogProviderTarget(queueTarget),
@@ -229,16 +270,15 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
await aiProviderRequestQueue.enqueue(queueTarget, { await aiProviderRequestQueue.enqueue(queueTarget, {
signal: controller.signal, signal: controller.signal,
onPositionChange: async requestsBefore => { onPositionChange: async requestsBefore => {
aiLog("debug", "run.queue.position", {requestId, cancelId: cancel.id, requestsBefore}); aiLog("debug", "run.queue.position", {cancelId: cancel.id, requestsBefore});
streamMessage.setStatus(Environment.getAiQueueText(options.provider, requestsBefore)); streamMessage.setStatus(Environment.getAiQueueText(options.provider, requestsBefore));
await streamMessage.flush(); await streamMessage.flush();
}, },
run: async (): Promise<null> => { run: async () => {
const queueWaitFinishedAt = Date.now(); const queueWaitFinishedAt = Date.now();
aiLog("info", "run.queue.dequeued", {requestId, cancelId: cancel.id}); aiLog("info", "run.queue.dequeued", {cancelId: cancel.id});
const downloads = attachmentsToDownloadedFiles(cached.attachments); const downloads = attachmentsToDownloadedFiles(cached.attachments);
aiLog("debug", "run.downloads.ready", { aiLog("debug", "run.downloads.ready", {
requestId,
count: downloads.length, count: downloads.length,
downloads: downloads.map(d => ({ downloads: downloads.map(d => ({
kind: d.kind, kind: d.kind,
@@ -249,66 +289,37 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
})), })),
}); });
try { try {
await executeUnifiedAiRequest(options, config, downloads, controller, streamMessage); const result = await executeUnifiedAiRequest(options, config, downloads, controller, streamMessage);
aiRequestStatus = "succeeded"; mistralLibraryId = result.mistralLibraryId;
aiLog("success", "run.queue.task.done", { aiLog("success", "run.queue.task.done", {
requestId,
cancelId: cancel.id, cancelId: cancel.id,
duration: aiLogDuration(queueWaitFinishedAt), duration: aiLogDuration(queueWaitFinishedAt),
mistralLibraryId,
}); });
} finally { } finally {
cleanupDownloads(downloads); cleanupDownloads(downloads);
aiLog("debug", "run.downloads.cleaned", {requestId, cancelId: cancel.id, count: downloads.length}); aiLog("debug", "run.downloads.cleaned", {cancelId: cancel.id, count: downloads.length});
} }
return null;
}, },
}); });
} catch (e) { } catch (e) {
if (controller.signal.aborted || isAbortError(e instanceof Error ? e : String(e))) { if (controller.signal.aborted || isAbortError(e)) {
aiRequestStatus = "aborted"; aiLog("warn", "run.aborted", {cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e});
aiRequestError = e instanceof Error ? e.message : String(e);
aiLog("warn", "run.aborted", {requestId, cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)});
streamMessage.replaceText(streamMessage.getText()); streamMessage.replaceText(streamMessage.getText());
await streamMessage.finish(); await streamMessage.finish();
} else { } else {
aiRequestStatus = "failed"; aiLog("error", "run.failed", {cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e});
aiRequestError = e instanceof Error ? e.message : String(e); await streamMessage.fail(e);
aiLog("error", "run.failed", {requestId, cancelId: cancel.id, duration: aiLogDuration(startedAt), error: e instanceof Error ? e : String(e)}); logError(e);
const errorMessage = e instanceof Error ? e.message : String(e);
await streamMessage.fail(e instanceof Error ? e : String(e));
try {
await streamMessage.storeInternalAttachment(await persistErrorArtifactAttachment({
provider: options.provider,
model: snapshotModel(options.provider, config),
message: errorMessage,
recoverable: false,
chatId: options.msg.chat.id,
messageId: options.msg.message_id,
}));
} catch (artifactError) {
logError(artifactError instanceof Error ? artifactError : String(artifactError));
}
logError(errorMessage);
} }
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
await AiRequestStore.put({ finishAiRequest(cancel.id);
requestId, if (mistralLibraryId) {
chatId: options.msg.chat.id, aiLog("debug", "run.mistral_library.cleanup", {mistralLibraryId});
messageId: options.msg.message_id, await deleteMistralLibrary(mistralLibraryId, config.mistralChatTarget);
responseMessageId, }
fromId: options.msg.from?.id ?? 0,
provider: options.provider,
model: snapshotModel(options.provider, config),
status: aiRequestStatus,
startedAt: aiRequestStartedAt,
finishedAt: new Date().toISOString(),
error: aiRequestError,
}).catch(logError);
recordAiRequestFinish(aiRequestStatus);
finishAiRequest(requestId);
aiLog("success", "run.finished", { aiLog("success", "run.finished", {
requestId,
cancelId: cancel.id, cancelId: cancel.id,
provider: providerName(options.provider), provider: providerName(options.provider),
duration: aiLogDuration(startedAt), duration: aiLogDuration(startedAt),
@@ -316,3 +327,7 @@ export async function runUnifiedAi(options: UnifiedRunOptions): Promise<void> {
}); });
} }
} }
export class UnifiedAiRunner {
static run = runUnifiedAi;
}
-73
View File
@@ -1,73 +0,0 @@
import type {PipelineFallbackPolicy, PipelineStageName} from "./types.js";
import {PIPELINE_ATTACHMENT_LIMIT_BYTES} from "./types.js";
export const USER_REQUEST_PIPELINE_STAGES: readonly PipelineStageName[] = [
"receive_request",
"audit_start",
"load_user_settings",
"collect_conversation_context",
"input_size_gate",
"download_attachments",
"normalize_attachments",
"persist_input_attachments",
"prepare_text_context",
"build_system_prompt",
"resolve_runtime",
"speech_to_text",
"document_rag",
"map_provider_messages",
"tool_rank",
"filter_tools",
"model_call",
"tool_loop",
"persist_output_artifacts",
"output_size_gate",
"text_to_speech",
"send_response",
"cleanup",
"audit_finish",
];
export const USER_REQUEST_ATTACHMENT_LIMIT_BYTES = PIPELINE_ATTACHMENT_LIMIT_BYTES;
export const DEFAULT_PIPELINE_FALLBACK_POLICIES: readonly PipelineFallbackPolicy[] = [
{
stage: "input_size_gate",
onUnavailable: "fail_request",
onFailed: "notify_user",
},
{
stage: "speech_to_text",
onUnavailable: "continue_without_stage",
onFailed: "continue_without_stage",
},
{
stage: "document_rag",
onUnavailable: "continue_without_stage",
onFailed: "notify_user",
},
{
stage: "tool_rank",
onUnavailable: "use_alternate_target",
onFailed: "use_alternate_target",
},
{
stage: "tool_loop",
onUnavailable: "continue_without_stage",
onFailed: "notify_user",
},
{
stage: "output_size_gate",
onUnavailable: "fail_request",
onFailed: "notify_user",
},
{
stage: "text_to_speech",
onUnavailable: "continue_without_stage",
onFailed: "continue_without_stage",
},
];
export function isPipelineStageName(value: string): value is PipelineStageName {
return (USER_REQUEST_PIPELINE_STAGES as readonly string[]).includes(value);
}
@@ -1,61 +0,0 @@
import type {
PipelineFallbackAction,
PipelineFallbackPolicy,
PipelineStageName,
PipelineStageStatus,
} from "./types.js";
export type PipelineFallbackReason = "unavailable" | "failed";
export type PipelineFallbackDecision = {
stage: PipelineStageName;
reason: PipelineFallbackReason;
action: PipelineFallbackAction;
shouldContinue: boolean;
shouldNotifyUser: boolean;
shouldFailRequest: boolean;
};
const DEFAULT_ACTION_BY_REASON: Record<PipelineFallbackReason, PipelineFallbackAction> = {
unavailable: "continue_without_stage",
failed: "fail_request",
};
export function resolvePipelineFallbackAction(params: {
stage: PipelineStageName;
reason: PipelineFallbackReason;
policies: readonly PipelineFallbackPolicy[];
}): PipelineFallbackAction {
const policy = params.policies.find(item => item.stage === params.stage);
if (!policy) return DEFAULT_ACTION_BY_REASON[params.reason];
return params.reason === "unavailable"
? policy.onUnavailable
: policy.onFailed;
}
export function decidePipelineFallback(params: {
stage: PipelineStageName;
reason: PipelineFallbackReason;
policies: readonly PipelineFallbackPolicy[];
}): PipelineFallbackDecision {
const action = resolvePipelineFallbackAction(params);
return {
stage: params.stage,
reason: params.reason,
action,
shouldContinue: action === "ignore"
|| action === "continue_without_stage"
|| action === "notify_user"
|| action === "use_alternate_target",
shouldNotifyUser: action === "notify_user",
shouldFailRequest: action === "fail_request",
};
}
export function fallbackReasonFromStageStatus(status: PipelineStageStatus): PipelineFallbackReason | undefined {
if (status === "skipped") return "unavailable";
if (status === "failed") return "failed";
return undefined;
}
@@ -1,12 +0,0 @@
import type {PipelineFallbackDecision} from "./fallback-executor.js";
export class PipelineRequestFailure extends Error {
constructor(public readonly decision: PipelineFallbackDecision, message: string) {
super(message);
this.name = "PipelineRequestFailure";
}
}
export function raisePipelineRequestFailure(decision: PipelineFallbackDecision, stageName: string): never {
throw new PipelineRequestFailure(decision, `Pipeline send failed at stage ${stageName} with fallback action ${decision.action}`);
}
@@ -1,16 +0,0 @@
import type {PipelineFallbackDecision} from "./fallback-executor.js";
export function fallbackNotificationKey(requestId: string, decision: PipelineFallbackDecision): string {
return `${requestId}:${decision.stage}:${decision.action}`;
}
export class PipelineFallbackNotificationRegistry {
private readonly notifiedKeys = new Set<string>();
claim(requestId: string, decision: PipelineFallbackDecision): boolean {
const key = fallbackNotificationKey(requestId, decision);
if (this.notifiedKeys.has(key)) return false;
this.notifiedKeys.add(key);
return true;
}
}
@@ -1,26 +0,0 @@
import {Localization} from "../../common/localization.js";
import type {PipelineFallbackAction, PipelineStageName} from "./types.js";
export function resolvePipelineFallbackText(
stage: PipelineStageName,
action: PipelineFallbackAction,
locale?: string,
): string | undefined {
if (action === "continue_without_stage") return undefined;
if (action === "fail_request") return Localization.text("pipelineFallback.failRequest", {}, "⚠️ I could not finish this request.", locale);
switch (stage) {
case "speech_to_text":
return Localization.text("pipelineFallback.speechToText", {}, "⚠️ Speech transcription failed, so I will continue without the audio transcript.", locale);
case "document_rag":
return Localization.text("pipelineFallback.documentRag", {}, "⚠️ Document retrieval failed, so I will answer without RAG.", locale);
case "tool_loop":
return Localization.text("pipelineFallback.toolLoop", {}, "⚠️ Tool execution failed, so I will continue without that tool.", locale);
case "text_to_speech":
return Localization.text("pipelineFallback.textToSpeech", {}, "⚠️ Text-to-speech failed, so I will continue without audio output.", locale);
default:
return action === "notify_user"
? Localization.text("pipelineFallback.notifyUser", {}, "⚠️ I hit a problem and need to continue with a fallback.", locale)
: Localization.text("pipelineFallback.generic", {}, "⚠️ I had to skip part of the request, but I can continue.", locale);
}
}
@@ -1,43 +0,0 @@
import type {Message} from "typescript-telegram-bot-api";
import {Localization} from "../../common/localization.js";
import {replyToMessage, logError} from "../../util/utils.js";
import type {PipelineFallbackDecision} from "./fallback-executor.js";
import {PipelineFallbackNotificationRegistry} from "./fallback-notifier-registry.js";
import {resolvePipelineFallbackText} from "./fallback-notifier-text.js";
export class PipelineFallbackNotifier {
private readonly registry = new PipelineFallbackNotificationRegistry();
constructor(
private readonly sourceMessage: Message,
private readonly responseLanguage?: string,
private readonly sendFallbackMessage: (text: string) => Promise<void> = async text => {
await replyToMessage({
message: this.sourceMessage,
text,
});
},
) {}
async notify(requestId: string, decision: PipelineFallbackDecision): Promise<{notified: boolean; text?: string}> {
if (!this.registry.claim(requestId, decision)) {
return {notified: false};
}
const locale = this.responseLanguage === "default"
? Localization.currentLocale()
: Localization.normalizeLocale(this.responseLanguage) ?? Localization.currentLocale();
const text = resolvePipelineFallbackText(decision.stage, decision.action, locale);
if (!text) {
return {notified: false};
}
try {
await this.sendFallbackMessage(text);
return {notified: true, text};
} catch (error) {
logError(error instanceof Error ? error : String(error));
return {notified: false, text};
}
}
}
@@ -1,15 +0,0 @@
import {AiProvider} from "../../model/ai-provider.js";
import type {RuntimeConfigSnapshot} from "../unified-ai-runner.shared.js";
import {aiLogProviderTarget} from "../../logging/ai-logger.js";
import {buildRankerTarget} from "../tool-ranker-pipeline.js";
import {providerChatTarget} from "../unified-ai-runner.shared.js";
export function buildToolRankFallbackTargetDetails(provider: AiProvider, config: RuntimeConfigSnapshot) {
const sourceTarget = buildRankerTarget(config, provider);
const alternateTarget = providerChatTarget(provider, config);
return {
sourceTarget: aiLogProviderTarget(sourceTarget),
alternateTarget: aiLogProviderTarget(alternateTarget),
};
}
-6
View File
@@ -1,6 +0,0 @@
export * from "./blueprint.js";
export * from "./fallback-executor.js";
export * from "./pipeline.js";
export * from "./size-gate.js";
export * from "./telegram-message-stages.js";
export * from "./types.js";
-134
View File
@@ -1,134 +0,0 @@
import {DEFAULT_PIPELINE_FALLBACK_POLICIES, USER_REQUEST_PIPELINE_STAGES} from "./blueprint.js";
import {decidePipelineFallback, type PipelineFallbackDecision} from "./fallback-executor.js";
import {raisePipelineRequestFailure} from "./fallback-failure.js";
import type {
PipelineAuditEvent,
PipelineFallbackPolicy,
PipelineStageName,
PipelineStageResult,
UserRequestPipelineStage,
UserRequestPipelineState,
} from "./types.js";
export type UserRequestPipelineOptions = {
stages: UserRequestPipelineStage[];
stageNames?: readonly PipelineStageName[];
fallbackPolicies?: readonly PipelineFallbackPolicy[];
onFallback?: (decision: PipelineFallbackDecision) => Promise<void> | void;
};
function nowIso(): string {
return new Date().toISOString();
}
function durationMs(startedAt: number): number {
return Date.now() - startedAt;
}
function stageEvent(event: PipelineAuditEvent): PipelineAuditEvent {
return event;
}
export class UserRequestPipeline {
private readonly stages = new Map<PipelineStageName, UserRequestPipelineStage>();
private readonly stageNames: readonly PipelineStageName[];
private readonly fallbackPolicies: readonly PipelineFallbackPolicy[];
private readonly onFallback?: (decision: PipelineFallbackDecision) => Promise<void> | void;
constructor(options: UserRequestPipelineOptions) {
for (const stage of options.stages) {
this.stages.set(stage.name, stage);
}
this.stageNames = options.stageNames ?? USER_REQUEST_PIPELINE_STAGES;
this.fallbackPolicies = options.fallbackPolicies ?? DEFAULT_PIPELINE_FALLBACK_POLICIES;
this.onFallback = options.onFallback;
}
async run(state: UserRequestPipelineState, signal: AbortSignal): Promise<UserRequestPipelineState> {
for (const stageName of this.stageNames) {
if (signal.aborted) throw new Error("Aborted");
const stage = this.stages.get(stageName);
if (!stage) {
const decision = decidePipelineFallback({
stage: stageName,
reason: "unavailable",
policies: this.fallbackPolicies,
});
await this.onFallback?.(decision);
state.audit.push(stageEvent({
stage: stageName,
status: "skipped",
startedAt: nowIso(),
finishedAt: nowIso(),
details: {
reason: "stage_not_registered",
fallbackAction: decision.action,
},
}));
if (decision.shouldFailRequest) {
raisePipelineRequestFailure(decision, stageName);
}
continue;
}
const startedAtMs = Date.now();
const startedAt = nowIso();
state.audit.push(stageEvent({
stage: stageName,
status: "running",
startedAt,
}));
try {
const result = await stage.run(state, signal);
this.applyStageResult(state, result);
state.audit.push(stageEvent({
stage: stageName,
status: result.status,
startedAt,
finishedAt: nowIso(),
durationMs: durationMs(startedAtMs),
details: result.fallbackAction || result.details
? {
...(result.details ?? {}),
...(result.fallbackAction ? {fallbackAction: result.fallbackAction} : {}),
}
: undefined,
}));
} catch (error) {
const decision = decidePipelineFallback({
stage: stageName,
reason: "failed",
policies: this.fallbackPolicies,
});
await this.onFallback?.(decision);
state.audit.push(stageEvent({
stage: stageName,
status: "failed",
startedAt,
finishedAt: nowIso(),
durationMs: durationMs(startedAtMs),
details: {fallbackAction: decision.action},
error: error instanceof Error ? error.message : String(error),
}));
if (decision.shouldFailRequest) {
raisePipelineRequestFailure(decision, stageName);
}
}
}
return state;
}
private applyStageResult(state: UserRequestPipelineState, result: PipelineStageResult): void {
if (result.artifacts?.length) {
state.artifacts.push(...result.artifacts);
}
if (result.attachments?.length) {
state.outputAttachments.push(...result.attachments.filter(attachment => attachment.direction === "output"));
state.inputAttachments.push(...result.attachments.filter(attachment => attachment.direction === "input"));
}
}
}
-51
View File
@@ -1,51 +0,0 @@
import {PIPELINE_ATTACHMENT_LIMIT_BYTES, type PersistentAttachment} from "./types.js";
export type AttachmentSizeGateResult =
| {
ok: true;
attachment: PersistentAttachment;
}
| {
ok: false;
attachment: PersistentAttachment;
limitBytes: number;
reason: string;
};
export function validateAttachmentSize(
attachment: PersistentAttachment,
limitBytes: number = PIPELINE_ATTACHMENT_LIMIT_BYTES,
): AttachmentSizeGateResult {
if (attachment.sizeBytes <= limitBytes) {
return {ok: true, attachment};
}
return {
ok: false,
attachment,
limitBytes,
reason: `Attachment ${attachment.fileName} is larger than ${limitBytes} bytes.`,
};
}
export function splitAttachmentsBySize(
attachments: readonly PersistentAttachment[],
limitBytes: number = PIPELINE_ATTACHMENT_LIMIT_BYTES,
): {
accepted: PersistentAttachment[];
rejected: AttachmentSizeGateResult[];
} {
const accepted: PersistentAttachment[] = [];
const rejected: AttachmentSizeGateResult[] = [];
for (const attachment of attachments) {
const result = validateAttachmentSize(attachment, limitBytes);
if (result.ok) {
accepted.push(result.attachment);
} else {
rejected.push(result);
}
}
return {accepted, rejected};
}
@@ -1,189 +0,0 @@
import type {Message} from "typescript-telegram-bot-api";
import {AiProvider} from "../../model/ai-provider";
import type {StoredMessage} from "../../model/stored-message";
import type {StoredAttachment} from "../../model/stored-attachment";
import {MessageStore} from "../../common/message-store";
import {Environment} from "../../common/environment";
import {
DEFAULT_AI_IMAGE_OUTPUT_MODE,
DEFAULT_AI_RESPONSE_LANGUAGE,
DEFAULT_AI_VOICE_MODE,
} from "../../common/user-ai-settings";
import {
cacheMessageAttachmentsWithRejections,
collectTelegramAttachmentDescriptors,
type RejectedTelegramAttachment,
} from "../telegram-attachments";
import {PIPELINE_ATTACHMENT_LIMIT_BYTES, type PersistentAttachment, type UserRequestPipelineState} from "./types";
import {UserRequestPipeline} from "./pipeline";
import type {UserRequestPipelineStage} from "./types";
type TelegramMessageAttachmentPipelineResult = {
state: UserRequestPipelineState;
storedMessage: StoredMessage;
attachments: StoredAttachment[];
rejected: RejectedTelegramAttachment[];
};
function nowIso(): string {
return new Date().toISOString();
}
function requestIdFor(msg: Message): string {
return `telegram:${msg.chat.id}:${msg.message_id}:${Date.now()}`;
}
function rejectedKey(attachment: Pick<RejectedTelegramAttachment, "fileId" | "fileName">): string {
return `${attachment.fileId}:${attachment.fileName}`;
}
function dedupeRejected(attachments: RejectedTelegramAttachment[]): RejectedTelegramAttachment[] {
const seen = new Set<string>();
const result: RejectedTelegramAttachment[] = [];
for (const attachment of attachments) {
const key = rejectedKey(attachment);
if (seen.has(key)) continue;
seen.add(key);
result.push(attachment);
}
return result;
}
function storedToPersistentAttachment(msg: Message, attachment: StoredAttachment): PersistentAttachment {
return {
direction: "input",
kind: attachment.kind,
fileId: attachment.fileId,
fileUniqueId: attachment.fileUniqueId,
fileName: attachment.fileName,
mimeType: attachment.mimeType,
sizeBytes: attachment.sizeBytes ?? 0,
cachePath: attachment.cachePath,
sha256: attachment.sha256,
sourceChatId: msg.chat.id,
sourceMessageId: msg.message_id,
};
}
export async function runTelegramMessageAttachmentPipeline(
msg: Message,
storedMessage: StoredMessage,
): Promise<TelegramMessageAttachmentPipelineResult> {
let downloadedAttachments: StoredAttachment[] = [];
let rejectedAttachments: RejectedTelegramAttachment[] = [];
let persistedMessage = storedMessage;
const state: UserRequestPipelineState = {
requestId: requestIdFor(msg),
chatId: msg.chat.id,
messageId: msg.message_id,
replyToMessageId: msg.reply_to_message?.message_id,
fromId: msg.from?.id ?? storedMessage.fromId,
receivedAt: nowIso(),
text: storedMessage.text ?? msg.text ?? msg.caption ?? "",
settings: {
provider: Environment.DEFAULT_AI_PROVIDER ?? AiProvider.OLLAMA,
responseLanguage: DEFAULT_AI_RESPONSE_LANGUAGE,
voiceMode: DEFAULT_AI_VOICE_MODE,
imageOutputMode: DEFAULT_AI_IMAGE_OUTPUT_MODE,
},
inputAttachments: [],
outputAttachments: [],
artifacts: [],
toolRankDecisions: [],
audit: [],
};
const stages: UserRequestPipelineStage[] = [
{
name: "input_size_gate",
async run() {
rejectedAttachments = dedupeRejected([
...rejectedAttachments,
...collectTelegramAttachmentDescriptors(msg)
.filter(attachment => (attachment.sizeBytes ?? 0) > PIPELINE_ATTACHMENT_LIMIT_BYTES)
.map(attachment => ({
kind: attachment.kind,
fileId: attachment.fileId,
fileUniqueId: attachment.fileUniqueId,
fileName: attachment.fileName,
mimeType: attachment.mimeType,
sizeBytes: attachment.sizeBytes ?? 0,
limitBytes: PIPELINE_ATTACHMENT_LIMIT_BYTES,
reason: "too_large" as const,
})),
]);
return {
stage: "input_size_gate",
status: rejectedAttachments.length ? "fallback" : "succeeded",
fallbackAction: rejectedAttachments.length ? "notify_user" : undefined,
};
},
},
{
name: "download_attachments",
async run() {
const result = await cacheMessageAttachmentsWithRejections(msg);
downloadedAttachments = result.attachments;
rejectedAttachments = dedupeRejected([...rejectedAttachments, ...result.rejected]);
return {
stage: "download_attachments",
status: "succeeded",
};
},
},
{
name: "normalize_attachments",
async run() {
return {
stage: "normalize_attachments",
status: "succeeded",
attachments: downloadedAttachments.map(attachment => storedToPersistentAttachment(msg, attachment)),
};
},
},
{
name: "persist_input_attachments",
async run() {
if (downloadedAttachments.length) {
persistedMessage = {
...persistedMessage,
attachments: downloadedAttachments,
};
persistedMessage = await MessageStore.put(persistedMessage);
}
return {
stage: "persist_input_attachments",
status: "succeeded",
};
},
},
];
const pipeline = new UserRequestPipeline({
stages,
stageNames: [
"input_size_gate",
"download_attachments",
"normalize_attachments",
"persist_input_attachments",
],
});
await pipeline.run(state, new AbortController().signal);
persistedMessage = await MessageStore.put({
...persistedMessage,
pipelineAudit: state.audit,
});
return {
state,
storedMessage: persistedMessage,
attachments: downloadedAttachments,
rejected: rejectedAttachments,
};
}
-233
View File
@@ -1,233 +0,0 @@
import type {AiProvider} from "../../model/ai-provider";
import type {StoredAttachmentKind} from "../../model/stored-attachment";
import type {
UserAiImageOutputMode,
UserAiResponseLanguage,
UserAiVoiceMode,
} from "../../common/user-ai-settings";
export const PIPELINE_ATTACHMENT_LIMIT_BYTES = 50 * 1024 * 1024;
export type PipelineStageName =
| "receive_request"
| "audit_start"
| "load_user_settings"
| "collect_conversation_context"
| "input_size_gate"
| "download_attachments"
| "normalize_attachments"
| "persist_input_attachments"
| "prepare_text_context"
| "build_system_prompt"
| "resolve_runtime"
| "speech_to_text"
| "document_rag"
| "map_provider_messages"
| "tool_rank"
| "filter_tools"
| "model_call"
| "tool_loop"
| "persist_output_artifacts"
| "output_size_gate"
| "text_to_speech"
| "send_response"
| "cleanup"
| "audit_finish";
export type PipelineStageStatus =
| "pending"
| "running"
| "succeeded"
| "skipped"
| "failed"
| "fallback";
export type PipelineFallbackAction =
| "ignore"
| "notify_user"
| "continue_without_stage"
| "use_alternate_target"
| "fail_request";
export type PipelineFallbackPolicy = {
stage: PipelineStageName;
onUnavailable: PipelineFallbackAction;
onFailed: PipelineFallbackAction;
};
export type PipelineUserSettings = {
provider: AiProvider;
responseLanguage: UserAiResponseLanguage;
contextSize?: number;
voiceMode: UserAiVoiceMode;
imageOutputMode: UserAiImageOutputMode;
};
export type PipelineRuntimeTarget = {
provider: AiProvider;
purpose:
| "chat"
| "toolRank"
| "documents"
| "speechToText"
| "textToSpeech"
| "outputImages"
| "tools";
model: string;
baseUrl?: string;
};
export type PipelineRuntimePlan = {
chat: PipelineRuntimeTarget;
toolRank?: PipelineRuntimeTarget;
documents?: PipelineRuntimeTarget;
speechToText?: PipelineRuntimeTarget;
textToSpeech?: PipelineRuntimeTarget;
outputImages?: PipelineRuntimeTarget;
tools?: PipelineRuntimeTarget;
};
export type PipelineAttachmentDirection = "input" | "output";
export type PersistentAttachment = {
id?: string;
direction: PipelineAttachmentDirection;
kind: StoredAttachmentKind | "file";
fileId?: string;
fileUniqueId?: string;
fileName: string;
mimeType?: string;
sizeBytes: number;
cachePath?: string;
sha256?: string;
sourceChatId?: number;
sourceMessageId?: number;
};
export type PipelineArtifactKind =
| "transcript"
| "rag"
| "tool_result"
| "generated_file"
| "tts_audio"
| "final_text"
| "error";
export type PipelineArtifactBase = {
id?: string;
kind: PipelineArtifactKind;
stage: PipelineStageName;
requestId?: string;
messageChatId?: number;
messageId?: number;
createdAt: string;
};
export type TranscriptArtifact = PipelineArtifactBase & {
kind: "transcript";
text: string;
sourceAttachmentIds: string[];
model?: string;
};
export type RagArtifact = PipelineArtifactBase & {
kind: "rag";
sourceAttachmentIds: string[];
provider: AiProvider;
extractedText?: string;
chunks?: Array<{
id: string;
sourceName: string;
text: string;
score?: number;
}>;
providerState?: {
vectorStoreIds?: string[];
libraryId?: string;
documentIds?: string[];
};
};
export type ToolResultArtifact = PipelineArtifactBase & {
kind: "tool_result";
toolName: string;
callId: string;
resultText: string;
outputAttachmentIds?: string[];
};
export type GeneratedFileArtifact = PipelineArtifactBase & {
kind: "generated_file" | "tts_audio";
attachmentId: string;
};
export type FinalTextArtifact = PipelineArtifactBase & {
kind: "final_text";
text: string;
};
export type ErrorArtifact = PipelineArtifactBase & {
kind: "error";
errorCode?: string;
message: string;
recoverable: boolean;
};
export type PipelineArtifact =
| TranscriptArtifact
| RagArtifact
| ToolResultArtifact
| GeneratedFileArtifact
| FinalTextArtifact
| ErrorArtifact;
export type PipelineAuditEvent = {
stage: PipelineStageName;
status: PipelineStageStatus;
startedAt?: string;
finishedAt?: string;
durationMs?: number;
provider?: AiProvider;
model?: string;
details?: Record<string, unknown>;
error?: string;
};
export type ToolRankDecision = {
provider: AiProvider;
round: number;
availableTools: string[];
selectedTools: string[];
usedRanker: boolean;
};
export type UserRequestPipelineState = {
requestId: string;
chatId: number;
messageId: number;
replyToMessageId?: number;
fromId: number;
receivedAt: string;
text: string;
settings: PipelineUserSettings;
runtime?: PipelineRuntimePlan;
inputAttachments: PersistentAttachment[];
outputAttachments: PersistentAttachment[];
artifacts: PipelineArtifact[];
toolRankDecisions: ToolRankDecision[];
audit: PipelineAuditEvent[];
};
export type PipelineStageResult = {
stage: PipelineStageName;
status: PipelineStageStatus;
artifacts?: PipelineArtifact[];
attachments?: PersistentAttachment[];
details?: Record<string, unknown>;
fallbackAction?: PipelineFallbackAction;
};
export interface UserRequestPipelineStage {
readonly name: PipelineStageName;
run(state: UserRequestPipelineState, signal: AbortSignal): Promise<PipelineStageResult>;
}

Some files were not shown because too many files have changed in this diff Show More