Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46a99605e6 | |||
| 321d185592 | |||
| a3f19f0413 | |||
| c613c636e1 | |||
| 7f5011b871 | |||
| 5b67e23060 |
@@ -43,6 +43,18 @@ 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=
|
||||||
|
|
||||||
@@ -91,6 +103,10 @@ 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
|
||||||
@@ -99,6 +115,14 @@ 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=
|
||||||
@@ -113,6 +137,7 @@ OPENAI_MAX_CONCURRENT_REQUESTS=3
|
|||||||
# OLLAMA_ADDRESS or OLLAMA_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
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
# 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 user’s 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.
|
||||||
@@ -7,6 +7,7 @@ Bot for Telegram with a lot of commands and AI (Ollama/Mistral/OpenAI) written i
|
|||||||
```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 (MISTRAL_API_KEY, OPENAI_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 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.
|
# Optional: set DATA_PATH if you want to override the default local storage directory.
|
||||||
```
|
```
|
||||||
@@ -27,6 +28,25 @@ 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.
|
`/exportdb` sends the SQLite file when available, plus a `.sql` dump and a JSON backup.
|
||||||
`/importdb` restores the database from the JSON backup format.
|
`/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`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
"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.37.0",
|
"openai": "^6.38.0",
|
||||||
"pg": "^8.20.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",
|
||||||
@@ -27,12 +27,12 @@
|
|||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@types/fluent-ffmpeg": "^2.1.28",
|
"@types/fluent-ffmpeg": "^2.1.28",
|
||||||
"@types/node": "^25.8.0",
|
"@types/node": "^25.9.1",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.59.3",
|
"typescript-eslint": "^8.59.4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
|
|
||||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
|
"@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/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="],
|
||||||
|
|
||||||
@@ -187,25 +187,25 @@
|
|||||||
|
|
||||||
"@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.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/type-utils": "8.59.3", "@typescript-eslint/utils": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw=="],
|
"@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-eslint/parser": ["@typescript-eslint/parser@8.59.3", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg=="],
|
"@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-eslint/project-service": ["@typescript-eslint/project-service@8.59.3", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.3", "@typescript-eslint/types": "^8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng=="],
|
"@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-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="],
|
"@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-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.3", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw=="],
|
"@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-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3", "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-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ=="],
|
"@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-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="],
|
"@typescript-eslint/types": ["@typescript-eslint/types@8.59.4", "", {}, "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q=="],
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.3", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.3", "@typescript-eslint/tsconfig-utils": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "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-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg=="],
|
"@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-eslint/utils": ["@typescript-eslint/utils@8.59.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="],
|
"@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=="],
|
||||||
|
|
||||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="],
|
"@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": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||||
|
|
||||||
@@ -399,7 +399,7 @@
|
|||||||
|
|
||||||
"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.37.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ=="],
|
"openai": ["openai@6.38.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g=="],
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
@@ -415,15 +415,15 @@
|
|||||||
|
|
||||||
"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.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="],
|
"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=="],
|
||||||
|
|
||||||
"pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="],
|
"pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="],
|
||||||
|
|
||||||
"pg-connection-string": ["pg-connection-string@2.12.0", "", {}, "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ=="],
|
"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-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
|
||||||
|
|
||||||
"pg-pool": ["pg-pool@3.13.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA=="],
|
"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-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="],
|
||||||
|
|
||||||
@@ -495,7 +495,7 @@
|
|||||||
|
|
||||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||||
|
|
||||||
"typescript-eslint": ["typescript-eslint@8.59.3", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.3", "@typescript-eslint/parser": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg=="],
|
"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=="],
|
||||||
|
|
||||||
@@ -551,12 +551,16 @@
|
|||||||
|
|
||||||
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
"@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=="],
|
||||||
|
|
||||||
"fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
"fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|||||||
Generated
+115
-115
@@ -17,8 +17,8 @@
|
|||||||
"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.37.0",
|
"openai": "^6.38.0",
|
||||||
"pg": "^8.20.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",
|
||||||
@@ -30,12 +30,12 @@
|
|||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@types/fluent-ffmpeg": "^2.1.28",
|
"@types/fluent-ffmpeg": "^2.1.28",
|
||||||
"@types/node": "^25.8.0",
|
"@types/node": "^25.9.1",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.59.3"
|
"typescript-eslint": "^8.59.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
@@ -1246,9 +1246,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.8.0",
|
"version": "25.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||||
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
|
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": ">=7.24.0 <7.24.7"
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
@@ -1286,17 +1286,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz",
|
||||||
"integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==",
|
"integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
"@typescript-eslint/scope-manager": "8.59.3",
|
"@typescript-eslint/scope-manager": "8.59.4",
|
||||||
"@typescript-eslint/type-utils": "8.59.3",
|
"@typescript-eslint/type-utils": "8.59.4",
|
||||||
"@typescript-eslint/utils": "8.59.3",
|
"@typescript-eslint/utils": "8.59.4",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||||
"ignore": "^7.0.5",
|
"ignore": "^7.0.5",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
"ts-api-utils": "^2.5.0"
|
"ts-api-utils": "^2.5.0"
|
||||||
@@ -1309,7 +1309,7 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@typescript-eslint/parser": "^8.59.3",
|
"@typescript-eslint/parser": "^8.59.4",
|
||||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
"typescript": ">=4.8.4 <6.1.0"
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
}
|
}
|
||||||
@@ -1325,16 +1325,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz",
|
||||||
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
|
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.59.3",
|
"@typescript-eslint/scope-manager": "8.59.4",
|
||||||
"@typescript-eslint/types": "8.59.3",
|
"@typescript-eslint/types": "8.59.4",
|
||||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
"@typescript-eslint/typescript-estree": "8.59.4",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1350,14 +1350,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/project-service": {
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
|
||||||
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
|
"integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.59.3",
|
"@typescript-eslint/tsconfig-utils": "^8.59.4",
|
||||||
"@typescript-eslint/types": "^8.59.3",
|
"@typescript-eslint/types": "^8.59.4",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1372,14 +1372,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
|
||||||
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
|
"integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.59.3",
|
"@typescript-eslint/types": "8.59.4",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.3"
|
"@typescript-eslint/visitor-keys": "8.59.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -1390,9 +1390,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
|
||||||
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
|
"integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1407,15 +1407,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/type-utils": {
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz",
|
||||||
"integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==",
|
"integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.59.3",
|
"@typescript-eslint/types": "8.59.4",
|
||||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
"@typescript-eslint/typescript-estree": "8.59.4",
|
||||||
"@typescript-eslint/utils": "8.59.3",
|
"@typescript-eslint/utils": "8.59.4",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"ts-api-utils": "^2.5.0"
|
"ts-api-utils": "^2.5.0"
|
||||||
},
|
},
|
||||||
@@ -1432,9 +1432,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
|
||||||
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
|
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1446,16 +1446,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
|
||||||
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
|
"integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/project-service": "8.59.3",
|
"@typescript-eslint/project-service": "8.59.4",
|
||||||
"@typescript-eslint/tsconfig-utils": "8.59.3",
|
"@typescript-eslint/tsconfig-utils": "8.59.4",
|
||||||
"@typescript-eslint/types": "8.59.3",
|
"@typescript-eslint/types": "8.59.4",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.3",
|
"@typescript-eslint/visitor-keys": "8.59.4",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"minimatch": "^10.2.2",
|
"minimatch": "^10.2.2",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
@@ -1513,16 +1513,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz",
|
||||||
"integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
|
"integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.9.1",
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
"@typescript-eslint/scope-manager": "8.59.3",
|
"@typescript-eslint/scope-manager": "8.59.4",
|
||||||
"@typescript-eslint/types": "8.59.3",
|
"@typescript-eslint/types": "8.59.4",
|
||||||
"@typescript-eslint/typescript-estree": "8.59.3"
|
"@typescript-eslint/typescript-estree": "8.59.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -1537,13 +1537,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
|
||||||
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
|
"integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.59.3",
|
"@typescript-eslint/types": "8.59.4",
|
||||||
"eslint-visitor-keys": "^5.0.0"
|
"eslint-visitor-keys": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1590,6 +1590,18 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@@ -1661,31 +1673,6 @@
|
|||||||
"proxy-from-env": "^2.1.0"
|
"proxy-from-env": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios/node_modules/agent-base": {
|
|
||||||
"version": "6.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
|
||||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/axios/node_modules/https-proxy-agent": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"agent-base": "6",
|
|
||||||
"debug": "4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -2575,6 +2562,19 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "6",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -2847,9 +2847,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/openai": {
|
"node_modules/openai": {
|
||||||
"version": "6.37.0",
|
"version": "6.38.0",
|
||||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.37.0.tgz",
|
"resolved": "https://registry.npmjs.org/openai/-/openai-6.38.0.tgz",
|
||||||
"integrity": "sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ==",
|
"integrity": "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openai": "bin/cli"
|
"openai": "bin/cli"
|
||||||
@@ -2959,14 +2959,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg": {
|
"node_modules/pg": {
|
||||||
"version": "8.20.0",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
|
||||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.12.0",
|
"pg-connection-string": "^2.13.0",
|
||||||
"pg-pool": "^3.13.0",
|
"pg-pool": "^3.14.0",
|
||||||
"pg-protocol": "^1.13.0",
|
"pg-protocol": "^1.14.0",
|
||||||
"pg-types": "2.2.0",
|
"pg-types": "2.2.0",
|
||||||
"pgpass": "1.0.5"
|
"pgpass": "1.0.5"
|
||||||
},
|
},
|
||||||
@@ -2974,7 +2974,7 @@
|
|||||||
"node": ">= 16.0.0"
|
"node": ">= 16.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"pg-cloudflare": "^1.3.0"
|
"pg-cloudflare": "^1.4.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"pg-native": ">=3.0.1"
|
"pg-native": ">=3.0.1"
|
||||||
@@ -2986,16 +2986,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg-cloudflare": {
|
"node_modules/pg-cloudflare": {
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||||
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/pg-connection-string": {
|
"node_modules/pg-connection-string": {
|
||||||
"version": "2.12.0",
|
"version": "2.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
|
||||||
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
|
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg-int8": {
|
"node_modules/pg-int8": {
|
||||||
@@ -3008,18 +3008,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg-pool": {
|
"node_modules/pg-pool": {
|
||||||
"version": "3.13.0",
|
"version": "3.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||||
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
|
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"pg": ">=8.0"
|
"pg": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg-protocol": {
|
"node_modules/pg-protocol": {
|
||||||
"version": "1.13.0",
|
"version": "1.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
|
||||||
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg-types": {
|
"node_modules/pg-types": {
|
||||||
@@ -3455,16 +3455,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript-eslint": {
|
"node_modules/typescript-eslint": {
|
||||||
"version": "8.59.3",
|
"version": "8.59.4",
|
||||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz",
|
||||||
"integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==",
|
"integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "8.59.3",
|
"@typescript-eslint/eslint-plugin": "8.59.4",
|
||||||
"@typescript-eslint/parser": "8.59.3",
|
"@typescript-eslint/parser": "8.59.4",
|
||||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
"@typescript-eslint/typescript-estree": "8.59.4",
|
||||||
"@typescript-eslint/utils": "8.59.3"
|
"@typescript-eslint/utils": "8.59.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
|||||||
+4
-4
@@ -22,8 +22,8 @@
|
|||||||
"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.37.0",
|
"openai": "^6.38.0",
|
||||||
"pg": "^8.20.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",
|
||||||
@@ -35,11 +35,11 @@
|
|||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@types/fluent-ffmpeg": "^2.1.28",
|
"@types/fluent-ffmpeg": "^2.1.28",
|
||||||
"@types/node": "^25.8.0",
|
"@types/node": "^25.9.1",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.59.3"
|
"typescript-eslint": "^8.59.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {AiModelCapabilities} from "../model/ai-model-capabilities.js";
|
|||||||
import {AiProvider} from "../model/ai-provider.js";
|
import {AiProvider} from "../model/ai-provider.js";
|
||||||
|
|
||||||
export type AiCapabilityName = keyof AiModelCapabilities;
|
export type AiCapabilityName = keyof AiModelCapabilities;
|
||||||
export type AiRuntimePurpose = AiCapabilityName | "chat";
|
export type AiRuntimePurpose = AiCapabilityName | "chat" | "memoryCompress";
|
||||||
|
|
||||||
export type AiRuntimeTarget = {
|
export type AiRuntimeTarget = {
|
||||||
provider: AiProvider;
|
provider: AiProvider;
|
||||||
@@ -24,6 +24,7 @@ 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"],
|
toolRank: ["TOOL_RANK", "TOOL_RANKER"],
|
||||||
audio: ["AUDIO"],
|
audio: ["AUDIO"],
|
||||||
documents: ["DOCUMENTS", "RAG", "EMBEDDING"],
|
documents: ["DOCUMENTS", "RAG", "EMBEDDING"],
|
||||||
@@ -155,6 +156,25 @@ export function resolveAiRuntimeTarget(
|
|||||||
return {provider, purpose, model, baseUrl, apiKey, systemPromptAdditions};
|
return {provider, purpose, model, baseUrl, apiKey, systemPromptAdditions};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
return left.provider === right.provider
|
return left.provider === right.provider
|
||||||
&& (left.baseUrl ?? "") === (right.baseUrl ?? "")
|
&& (left.baseUrl ?? "") === (right.baseUrl ?? "")
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {AttachmentKind, AiRuntimeTarget, RuntimeConfigSnapshot} from "./uni
|
|||||||
import type {OpenAIChatMessage} from "./openai-chat-message";
|
import type {OpenAIChatMessage} from "./openai-chat-message";
|
||||||
import type {MistralChatMessage} from "./mistral-chat-message";
|
import type {MistralChatMessage} from "./mistral-chat-message";
|
||||||
import type {OllamaChatMessage} from "./ollama-chat-message";
|
import type {OllamaChatMessage} from "./ollama-chat-message";
|
||||||
|
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
|
||||||
|
|
||||||
export type ConversationAttachment = {
|
export type ConversationAttachment = {
|
||||||
kind: AttachmentKind;
|
kind: AttachmentKind;
|
||||||
@@ -267,11 +268,13 @@ function buildSystemInstruction(
|
|||||||
responseLanguage: UserAiResponseLanguage,
|
responseLanguage: UserAiResponseLanguage,
|
||||||
includePythonToolPrompt: boolean,
|
includePythonToolPrompt: boolean,
|
||||||
additions?: string | null,
|
additions?: string | null,
|
||||||
|
memoryInstruction?: string | null,
|
||||||
): string {
|
): string {
|
||||||
return [
|
return [
|
||||||
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
|
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
|
||||||
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
|
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
|
||||||
additions?.trim() ? additions.trim() : 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");
|
||||||
}
|
}
|
||||||
@@ -310,11 +313,12 @@ export async function buildConversationSnapshot(
|
|||||||
if (turn.bot) return sum;
|
if (turn.bot) return sum;
|
||||||
return sum + turn.attachments.filter(attachment => attachment.kind === "image").length;
|
return sum + turn.attachments.filter(attachment => attachment.kind === "image").length;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
const memoryInstruction = await buildUserMemoryPrompt(msg.from?.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
turns,
|
turns,
|
||||||
imageCount,
|
imageCount,
|
||||||
systemInstruction: buildSystemInstruction(config, responseLanguage, includePythonToolPrompt, runtimeTarget.systemPromptAdditions),
|
systemInstruction: buildSystemInstruction(config, responseLanguage, includePythonToolPrompt, runtimeTarget.systemPromptAdditions, memoryInstruction),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export async function prepareDocumentRag(
|
|||||||
const documents = downloads.filter(download => download.kind === "document");
|
const documents = downloads.filter(download => download.kind === "document");
|
||||||
if (!documents.length) return undefined;
|
if (!documents.length) return undefined;
|
||||||
|
|
||||||
|
if (provider === AiProvider.OPENAI && config.openAiBackend === "compatible") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case AiProvider.OPENAI: {
|
case AiProvider.OPENAI: {
|
||||||
const openAi = createOpenAiClient(config.openAiChatTarget);
|
const openAi = createOpenAiClient(config.openAiChatTarget);
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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] : [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
ResponseInputMessageContentList,
|
ResponseInputMessageContentList,
|
||||||
ResponseOutputMessage,
|
ResponseOutputMessage,
|
||||||
} from "openai/resources/responses/responses";
|
} from "openai/resources/responses/responses";
|
||||||
|
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
|
||||||
|
|
||||||
type OpenAIInputChatMessage = {
|
type OpenAIInputChatMessage = {
|
||||||
type: "message";
|
type: "message";
|
||||||
@@ -17,3 +18,5 @@ type OpenAIOutputChatMessage = {
|
|||||||
} & Pick<ResponseOutputMessage, "id" | "status">;
|
} & Pick<ResponseOutputMessage, "id" | "status">;
|
||||||
|
|
||||||
export type OpenAIChatMessage = OpenAIInputChatMessage | OpenAIOutputChatMessage;
|
export type OpenAIChatMessage = OpenAIInputChatMessage | OpenAIOutputChatMessage;
|
||||||
|
|
||||||
|
export type OpenAICompatibleChatMessage = ChatCompletionMessageParam;
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,12 @@ function normalizeToolArguments(value: unknown): string {
|
|||||||
return JSON.stringify(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[] {
|
export function extractOpenAiToolCalls(response: unknown): ToolCallData[] {
|
||||||
const output = isRecord(response) && Array.isArray(response.output) ? response.output : [];
|
const output = isRecord(response) && Array.isArray(response.output) ? response.output : [];
|
||||||
|
|
||||||
@@ -32,6 +38,86 @@ export function extractOpenAiTextDelta(input: unknown): string {
|
|||||||
return event?.type === "response.output_text.delta" ? event.delta ?? "" : "";
|
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[] {
|
export function extractOpenAiStreamingToolCalls(input: unknown): ToolCallData[] {
|
||||||
const event = input as ResponseStreamEvent | undefined;
|
const event = input as ResponseStreamEvent | undefined;
|
||||||
if (event?.type === "response.output_item.added" && isRecord(event.item) && event.item.type === "function_call") {
|
if (event?.type === "response.output_item.added" && isRecord(event.item) && event.item.type === "function_call") {
|
||||||
|
|||||||
@@ -196,7 +196,8 @@ 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 result = await getModelCapabilities(provider, runtimeTarget.model, target?.purpose ?? "chat") ?? buildCapabilities({});
|
const targetPurpose = target?.purpose && target.purpose !== "memoryCompress" ? target.purpose : "chat";
|
||||||
|
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")) {
|
if (provider === AiProvider.OPENAI && (capabilityName === "vision" || capabilityName === "ocr")) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {AiProvider} from "../model/ai-provider.js";
|
|||||||
import {getTools} from "./tools/registry.js";
|
import {getTools} from "./tools/registry.js";
|
||||||
import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search.js";
|
import {WEB_SEARCH_TOOL_NAME} from "./tools/web-search.js";
|
||||||
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator.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" | "mistral";
|
||||||
|
|
||||||
@@ -26,6 +27,11 @@ export function getOpenAITools(forCreator?: boolean): AiTool[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -79,3 +85,20 @@ export function getProviderTools(provider: AiProvider, forCreator?: boolean): Ai
|
|||||||
return getOpenAITools(forCreator);
|
return getOpenAITools(forCreator);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -102,6 +102,100 @@ export const TOOL_RANKER_TOOL_INFOS = {
|
|||||||
example("где определён BotService?", ["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: tool(
|
||||||
"create_file",
|
"create_file",
|
||||||
"Create a new small file.",
|
"Create a new small file.",
|
||||||
@@ -352,6 +446,20 @@ function toolNamesFromTool(tool: BoundaryValue): string[] {
|
|||||||
return name ? [name] : [];
|
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 {
|
export function getToolRankerToolInfo(name: string): ToolRankerToolInfo | undefined {
|
||||||
return TOOL_RANKER_TOOL_INFOS[name as ToolRankerToolName];
|
return TOOL_RANKER_TOOL_INFOS[name as ToolRankerToolName];
|
||||||
}
|
}
|
||||||
@@ -363,10 +471,25 @@ export function getToolRankerToolInfos(names: readonly string[]): ToolRankerTool
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getToolRankerAvailableToolInfos(availableTools: readonly BoundaryValue[]): ToolRankerToolInfo[] {
|
export function getToolRankerAvailableToolInfos(availableTools: readonly BoundaryValue[]): ToolRankerToolInfo[] {
|
||||||
return getToolRankerToolInfos([
|
const infos = new Map<string, ToolRankerToolInfo>();
|
||||||
"no_tool",
|
|
||||||
...availableTools.flatMap(toolNamesFromTool),
|
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 {
|
function renderToolLine(tool: ToolRankerToolInfo, compact: boolean): string {
|
||||||
@@ -414,6 +537,16 @@ function buildPriorityLines(tools: readonly ToolRankerToolInfo[]): string[] {
|
|||||||
pushIfAvailable("read_file", "known local file path -> read_file");
|
pushIfAvailable("read_file", "known local file path -> read_file");
|
||||||
pushIfAvailable("list_directory", "project structure or directory listing -> list_directory");
|
pushIfAvailable("list_directory", "project structure or directory listing -> list_directory");
|
||||||
pushIfAvailable("search_files", "local file/content search or unknown file path -> search_files");
|
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("edit_file_patch", "targeted existing file edit -> edit_file_patch");
|
||||||
pushIfAvailable("update_file", "full existing file replacement -> update_file");
|
pushIfAvailable("update_file", "full existing file replacement -> update_file");
|
||||||
pushIfAvailable("create_file", "small new file -> create_file");
|
pushIfAvailable("create_file", "small new file -> create_file");
|
||||||
@@ -471,7 +604,7 @@ export function buildToolRankerSystemPrompt(params: {
|
|||||||
const includeExamples = params.includeExamples ?? false;
|
const includeExamples = params.includeExamples ?? false;
|
||||||
const maxExamplesPerTool = Math.max(0, params.maxExamplesPerTool ?? 1);
|
const maxExamplesPerTool = Math.max(0, params.maxExamplesPerTool ?? 1);
|
||||||
const compact = params.compact ?? true;
|
const compact = params.compact ?? true;
|
||||||
const availableTools = getToolRankerToolInfos(params.availableTools.map(tool => tool.name));
|
const availableTools = params.availableTools;
|
||||||
const availableToolNames = availableTools.map(tool => tool.name);
|
const availableToolNames = availableTools.map(tool => tool.name);
|
||||||
|
|
||||||
const sections: string[] = [
|
const sections: string[] = [
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types.js";
|
||||||
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";
|
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index.js";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
import {AiJsonObject} from "../tool-types";
|
import {AiJsonObject} from "../tool-types.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("create-note");
|
const logger = toolsLogger.child("create-note");
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
import {AiJsonObject} from "../tool-types";
|
import {AiJsonObject} from "../tool-types";
|
||||||
|
|
||||||
export const getCurrentDateTimeTool = {
|
export const getCurrentDateTimeTool = {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
|
|
||||||
import {Environment} from "../../common/environment";
|
import {Environment} from "../../common/environment.js";
|
||||||
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types";
|
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types.js";
|
||||||
import {
|
import {
|
||||||
MAX_COPY_ENTRIES,
|
MAX_COPY_ENTRIES,
|
||||||
MAX_COPY_TOTAL_BYTES,
|
MAX_COPY_TOTAL_BYTES,
|
||||||
@@ -23,8 +23,8 @@ import {
|
|||||||
MAX_PATCH_SEARCH_BYTES,
|
MAX_PATCH_SEARCH_BYTES,
|
||||||
MAX_STREAM_WRITE_IDLE_MS,
|
MAX_STREAM_WRITE_IDLE_MS,
|
||||||
MAX_STREAM_WRITE_SESSIONS,
|
MAX_STREAM_WRITE_SESSIONS,
|
||||||
} from "./limits";
|
} from "./limits.js";
|
||||||
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils";
|
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils.js";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Public types and schemas
|
// Public types and schemas
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types.js";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
import {AiJsonObject} from "../tool-types";
|
import {AiJsonObject} from "../tool-types.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("market-rates");
|
const logger = toolsLogger.child("market-rates");
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types.js";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {readdir, readFile, stat, unlink, writeFile} from "node:fs/promises";
|
import {readdir, readFile, stat, unlink, writeFile} from "node:fs/promises";
|
||||||
import {notesDir, notesRootFile} from "../../index";
|
import {notesDir, notesRootFile} from "../../index.js";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {AiJsonObject} from "../tool-types";
|
import {AiJsonObject} from "../tool-types.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("notes");
|
const logger = toolsLogger.child("notes");
|
||||||
|
|
||||||
|
|||||||
+117
-51
@@ -1,17 +1,17 @@
|
|||||||
import {Environment} from "../../common/environment";
|
import {Environment} from "../../common/environment.js";
|
||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types.js";
|
||||||
import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search";
|
import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search.js";
|
||||||
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime";
|
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime.js";
|
||||||
import {shellExecute, shellExecuteTool} from "./shell";
|
import {shellExecute, shellExecuteTool} from "./shell.js";
|
||||||
import {ToolHandler} from "./types";
|
import {ToolHandler} from "./types.js";
|
||||||
import {getWeather, getWeatherTool} from "./weather";
|
import {getWeather, getWeatherTool} from "./weather.js";
|
||||||
import {
|
import {
|
||||||
GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
|
GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
|
||||||
getFinancialMarketData,
|
getFinancialMarketData,
|
||||||
getFinancialMarketDataToolPrompt,
|
getFinancialMarketDataToolPrompt,
|
||||||
getMarketRates
|
getMarketRates
|
||||||
} from "./market-rates";
|
} from "./market-rates.js";
|
||||||
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator";
|
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator.js";
|
||||||
import {
|
import {
|
||||||
beginFileWrite,
|
beginFileWrite,
|
||||||
beginFileWriteTool,
|
beginFileWriteTool,
|
||||||
@@ -44,11 +44,14 @@ import {
|
|||||||
updateFileTool,
|
updateFileTool,
|
||||||
writeFileChunk,
|
writeFileChunk,
|
||||||
writeFileChunkTool
|
writeFileChunkTool
|
||||||
} from "./files";
|
} from "./files.js";
|
||||||
|
import {executeMemoryTool, memoryToolPrompt, memoryTools, type MemoryToolName} from "./user-memory.js";
|
||||||
|
import {getMcpToolHandlers, getMcpToolPrompts, getMcpTools} from "../mcp/mcp-registry.js";
|
||||||
|
|
||||||
export const defaultTools: AiTool[] = [
|
export const defaultTools: AiTool[] = [
|
||||||
getCurrentDateTimeTool,
|
getCurrentDateTimeTool,
|
||||||
getFinancialMarketData,
|
getFinancialMarketData,
|
||||||
|
...memoryTools,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const fileTools = [
|
export const fileTools = [
|
||||||
@@ -72,44 +75,67 @@ export const fileTools = [
|
|||||||
deletePathTool,
|
deletePathTool,
|
||||||
] satisfies AiTool[];
|
] satisfies AiTool[];
|
||||||
|
|
||||||
// export const notesFileTools: AiTool[] = [
|
function parseToolNameSet(raw: string | undefined): Set<string> | undefined {
|
||||||
// createNoteTool,
|
if (!raw?.trim()) return undefined;
|
||||||
// listNotesTool,
|
|
||||||
// getNoteContentTool,
|
const names = raw
|
||||||
// updateNoteContentTool,
|
.split(",")
|
||||||
// deleteNoteTool,
|
.map(item => item.trim().toLowerCase())
|
||||||
// sendNoteAsFileTool,
|
.filter(Boolean);
|
||||||
// searchNotesTool
|
|
||||||
// ]
|
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) => {
|
export const getTools = (forCreator?: boolean) => {
|
||||||
const tools: AiTool[] = [
|
const tools: AiTool[] = [];
|
||||||
...defaultTools,
|
|
||||||
// ...notesFileTools
|
if (Environment.DISABLE_LOCAL_TOOLS) {
|
||||||
];
|
tools.push(...getMcpTools());
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
tools.push(...filterEnabledTools(defaultTools));
|
||||||
|
|
||||||
if (Environment.BRAVE_SEARCH_API_KEY) {
|
if (Environment.BRAVE_SEARCH_API_KEY) {
|
||||||
tools.push(webSearchTool);
|
tools.push(...filterEnabledTools([webSearchTool]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
|
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
|
||||||
tools.push(getWeatherTool);
|
tools.push(...filterEnabledTools([getWeatherTool]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
|
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
|
||||||
tools.push(...fileTools);
|
tools.push(...filterEnabledTools(fileTools));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (forCreator) {
|
if (forCreator) {
|
||||||
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
||||||
tools.push(pythonInterpreterTool);
|
tools.push(...filterEnabledTools([pythonInterpreterTool]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Environment.ENABLE_UNSAFE_EVAL) {
|
if (Environment.ENABLE_UNSAFE_EVAL) {
|
||||||
tools.push(shellExecuteTool);
|
tools.push(...filterEnabledTools([shellExecuteTool]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tools.push(...getMcpTools());
|
||||||
|
|
||||||
return tools;
|
return tools;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,44 +161,83 @@ export const fileToolHandlers = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getToolHandlers = () => {
|
export const getToolHandlers = () => {
|
||||||
let handlers: Record<string, ToolHandler> = {
|
const handlers: Record<string, ToolHandler> = {
|
||||||
get_datetime: getCurrentDateTime,
|
...getMcpToolHandlers(),
|
||||||
get_financial_market_data: getMarketRates,
|
|
||||||
|
|
||||||
// create_note: createNote,
|
|
||||||
// list_notes: listNotes,
|
|
||||||
// get_note_content: getNoteContent,
|
|
||||||
// update_note_content: updateNoteContent,
|
|
||||||
// delete_note: deleteNote,
|
|
||||||
// send_note_as_file: sendNoteAsFile,
|
|
||||||
// search_notes: searchNotes,
|
|
||||||
|
|
||||||
...fileToolHandlers,
|
|
||||||
|
|
||||||
|
|
||||||
python_interpreter: runPythonInterpreter,
|
|
||||||
|
|
||||||
shell_execute: shellExecute,
|
|
||||||
|
|
||||||
web_search: webSearch,
|
|
||||||
|
|
||||||
get_weather: getWeather,
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (Environment.DISABLE_LOCAL_TOOLS) {
|
||||||
|
return 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 (isLocalToolEnabled("list_directory")) handlers.list_directory = listDirectory;
|
||||||
|
if (isLocalToolEnabled("search_files")) handlers.search_files = searchFiles;
|
||||||
|
if (isLocalToolEnabled("create_file")) handlers.create_file = createFile;
|
||||||
|
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 (isLocalToolEnabled("shell_execute")) handlers.shell_execute = shellExecute;
|
||||||
|
if (isLocalToolEnabled("web_search")) handlers.web_search = webSearch;
|
||||||
|
if (isLocalToolEnabled("get_weather")) handlers.get_weather = getWeather;
|
||||||
|
|
||||||
return handlers;
|
return handlers;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getToolPrompts(toolNames: string[]): string[] {
|
export function getToolPrompts(toolNames: string[]): string[] {
|
||||||
|
if (Environment.DISABLE_LOCAL_TOOLS) {
|
||||||
|
return getMcpToolPrompts(toolNames);
|
||||||
|
}
|
||||||
|
|
||||||
const prompts: string[] = [];
|
const prompts: string[] = [];
|
||||||
|
const memoryToolNames = new Set(memoryTools.map(tool => tool.function.name));
|
||||||
|
let memoryPromptAdded = false;
|
||||||
|
|
||||||
for (const toolName of toolNames) {
|
for (const toolName of toolNames) {
|
||||||
|
if (!isLocalToolEnabled(toolName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!prompts.includes(fileToolsToolPrompt) &&
|
if (!prompts.includes(fileToolsToolPrompt) &&
|
||||||
fileTools.map(t => t.function.name).includes(toolName)) {
|
fileTools.map(t => t.function.name).includes(toolName)) {
|
||||||
prompts.push(fileToolsToolPrompt);
|
prompts.push(fileToolsToolPrompt);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (memoryToolNames.has(toolName)) {
|
||||||
|
if (!memoryPromptAdded) {
|
||||||
|
prompts.push(memoryToolPrompt);
|
||||||
|
memoryPromptAdded = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
switch (toolName) {
|
switch (toolName) {
|
||||||
case GET_FINANCIAL_MARKET_DATA_TOOL_NAME:
|
case GET_FINANCIAL_MARKET_DATA_TOOL_NAME:
|
||||||
prompts.push(getFinancialMarketDataToolPrompt);
|
prompts.push(getFinancialMarketDataToolPrompt);
|
||||||
@@ -185,5 +250,6 @@ export function getToolPrompts(toolNames: string[]): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prompts.push(...getMcpToolPrompts(toolNames));
|
||||||
return prompts;
|
return prompts;
|
||||||
}
|
}
|
||||||
+12
-7
@@ -1,14 +1,19 @@
|
|||||||
import {getToolHandlers} from "./registry";
|
import {getToolHandlers} from "./registry.js";
|
||||||
import {normalizeToolArguments} from "./utils";
|
import {normalizeToolArguments} from "./utils.js";
|
||||||
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator";
|
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator.js";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
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: AiJsonValue): string {
|
||||||
if (typeof result === "string") return result;
|
if (typeof result === "string") return result;
|
||||||
@@ -48,7 +53,7 @@ export async function executeToolCall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const arguments1 = normalizeToolArguments(args, userId);
|
const arguments1 = normalizeToolArguments(args, userId);
|
||||||
const result = await handler(arguments1);
|
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;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types.js";
|
||||||
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";
|
import {notesDir, notesRootFile} from "../../index.js";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
import {AiJsonObject, AiJsonValue} from "../tool-types.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("search-notes");
|
const logger = toolsLogger.child("search-notes");
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {AiTool} from "../tool-types";
|
import {AiTool} from "../tool-types";
|
||||||
import {runCommand} from "../../util/utils";
|
import {runCommand} from "../../util/utils.js";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
import {AiJsonObject} from "../tool-types";
|
import {AiJsonObject} from "../tool-types";
|
||||||
|
|
||||||
export const shellExecuteTool = {
|
export const shellExecuteTool = {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
||||||
|
import type {ToolRuntimeContext} from "./runtime.js";
|
||||||
|
|
||||||
export type ToolHandler = (args?: AiJsonObject) => Promise<AiJsonValue | string | null | undefined> | AiJsonValue | string | null | undefined;
|
export type ToolHandler = (args?: AiJsonObject, context?: ToolRuntimeContext) => Promise<AiJsonValue | string | null | undefined> | AiJsonValue | string | null | undefined;
|
||||||
|
|||||||
@@ -0,0 +1,582 @@
|
|||||||
|
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)};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Ollama} from "ollama";
|
import {Ollama} from "ollama";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
||||||
import type {BoundaryValue} from "../../common/boundary-types";
|
import type {BoundaryValue} from "../../common/boundary-types";
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("weather");
|
const logger = toolsLogger.child("weather");
|
||||||
import {Environment} from "../../common/environment";
|
import {Environment} from "../../common/environment.js";
|
||||||
import {logError} from "../../util/utils";
|
import {logError} from "../../util/utils.js";
|
||||||
import {AiJsonObject, AiTool} from "../tool-types";
|
import {AiJsonObject, AiTool} from "../tool-types.js";
|
||||||
import {asNonEmptyString} from "./utils";
|
import {asNonEmptyString} from "./utils.js";
|
||||||
|
|
||||||
export const getWeatherTool = {
|
export const getWeatherTool = {
|
||||||
type: "function",
|
type: "function",
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {toolsLogger} from "./tool-logger";
|
import {toolsLogger} from "./tool-logger.js";
|
||||||
|
|
||||||
const logger = toolsLogger.child("brave-search");
|
const logger = toolsLogger.child("brave-search");
|
||||||
import {Environment} from "../../common/environment";
|
import {Environment} from "../../common/environment.js";
|
||||||
import {logError} from "../../util/utils";
|
import {logError} from "../../util/utils.js";
|
||||||
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types";
|
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types.js";
|
||||||
import {asBoolean, asNonEmptyString} from "./utils";
|
import {asBoolean, asNonEmptyString} from "./utils.js";
|
||||||
|
|
||||||
type BraveSearchProfile = {
|
type BraveSearchProfile = {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "./unified-ai-runner.shared";
|
} from "./unified-ai-runner.shared";
|
||||||
import {runToolRankStage} from "./tool-rank-stage";
|
import {runToolRankStage} from "./tool-rank-stage";
|
||||||
import {runOpenAi} from "./unified-ai-runner.openai";
|
import {runOpenAi} from "./unified-ai-runner.openai";
|
||||||
|
import {runOpenAiCompatible} from "./unified-ai-runner.openai-compatible";
|
||||||
import {runOllama} from "./unified-ai-runner.ollama";
|
import {runOllama} from "./unified-ai-runner.ollama";
|
||||||
import {runMistral} from "./unified-ai-runner.mistral";
|
import {runMistral} from "./unified-ai-runner.mistral";
|
||||||
import {summarizeModelOutput} from "./response-model-output";
|
import {summarizeModelOutput} from "./response-model-output";
|
||||||
@@ -80,6 +81,21 @@ async function runProviderModelCall(params: {
|
|||||||
|
|
||||||
switch (options.provider) {
|
switch (options.provider) {
|
||||||
case AiProvider.OPENAI:
|
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(
|
await runOpenAi(
|
||||||
options.msg,
|
options.msg,
|
||||||
prepared.chatMessages as OpenAIChatMessage[],
|
prepared.chatMessages as OpenAIChatMessage[],
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../loggi
|
|||||||
import {AiProvider} from "../model/ai-provider";
|
import {AiProvider} from "../model/ai-provider";
|
||||||
import {getProviderAdapter} from "./provider-adapters";
|
import {getProviderAdapter} from "./provider-adapters";
|
||||||
import {runToolRankStage} from "./tool-rank-stage";
|
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,
|
MAX_TOOL_ROUNDS,
|
||||||
@@ -66,7 +68,7 @@ export async function runMistral(
|
|||||||
streamMessage,
|
streamMessage,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
const filteredTools = rankResult.filteredTools;
|
const filteredTools = ensureToolsSelected(availableTools, rankResult.filteredTools, MEMORY_TOOL_NAMES);
|
||||||
const requestTools = filteredTools.length ? filteredTools : undefined;
|
const requestTools = filteredTools.length ? filteredTools : undefined;
|
||||||
|
|
||||||
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
|
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
|
||||||
@@ -113,7 +115,11 @@ export async function runMistral(
|
|||||||
userId: msg.from?.id,
|
userId: msg.from?.id,
|
||||||
toolCalls: calls,
|
toolCalls: calls,
|
||||||
streamMessage,
|
streamMessage,
|
||||||
toolContext,
|
toolContext: {
|
||||||
|
...toolContext,
|
||||||
|
provider: AiProvider.MISTRAL,
|
||||||
|
runtimeTarget: config.mistralChatTarget,
|
||||||
|
},
|
||||||
toolMemory,
|
toolMemory,
|
||||||
adapter,
|
adapter,
|
||||||
appendTargets: [messages, requestMessages],
|
appendTargets: [messages, requestMessages],
|
||||||
@@ -183,7 +189,11 @@ export async function runMistral(
|
|||||||
userId: msg.from?.id,
|
userId: msg.from?.id,
|
||||||
toolCalls: calls,
|
toolCalls: calls,
|
||||||
streamMessage,
|
streamMessage,
|
||||||
toolContext,
|
toolContext: {
|
||||||
|
...toolContext,
|
||||||
|
provider: AiProvider.MISTRAL,
|
||||||
|
runtimeTarget: config.mistralChatTarget,
|
||||||
|
},
|
||||||
toolMemory,
|
toolMemory,
|
||||||
adapter,
|
adapter,
|
||||||
appendTargets: [messages, requestMessages],
|
appendTargets: [messages, requestMessages],
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {createOllamaClient} 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 {getProviderAdapter} from "./provider-adapters";
|
import {getProviderAdapter} from "./provider-adapters";
|
||||||
import {runToolRankStage} from "./tool-rank-stage";
|
import {runToolRankStage} from "./tool-rank-stage";
|
||||||
|
import {ensureToolsSelected} from "./tool-mappers.js";
|
||||||
|
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
allToolSchemaNames,
|
allToolSchemaNames,
|
||||||
@@ -203,7 +205,7 @@ export async function runOllama(
|
|||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredTools = [...new Set(rankResult.filteredTools as Tool[])];
|
const filteredTools = [...new Set(ensureToolsSelected(availableOllamaTools, rankResult.filteredTools as Tool[], MEMORY_TOOL_NAMES) as Tool[])];
|
||||||
activeToolNames = filteredTools.map(t => t.function.name ?? "");
|
activeToolNames = filteredTools.map(t => t.function.name ?? "");
|
||||||
if (filteredTools.length > 0) {
|
if (filteredTools.length > 0) {
|
||||||
request.tools = [...filteredTools];
|
request.tools = [...filteredTools];
|
||||||
@@ -297,7 +299,11 @@ export async function runOllama(
|
|||||||
userId: msg.from?.id,
|
userId: msg.from?.id,
|
||||||
toolCalls: calls,
|
toolCalls: calls,
|
||||||
streamMessage,
|
streamMessage,
|
||||||
toolContext,
|
toolContext: {
|
||||||
|
...toolContext,
|
||||||
|
provider: AiProvider.OLLAMA,
|
||||||
|
runtimeTarget: target,
|
||||||
|
},
|
||||||
toolMemory,
|
toolMemory,
|
||||||
adapter,
|
adapter,
|
||||||
appendTargets: [messages],
|
appendTargets: [messages],
|
||||||
@@ -429,7 +435,11 @@ export async function runOllama(
|
|||||||
userId: msg.from?.id,
|
userId: msg.from?.id,
|
||||||
toolCalls: calls,
|
toolCalls: calls,
|
||||||
streamMessage,
|
streamMessage,
|
||||||
toolContext,
|
toolContext: {
|
||||||
|
...toolContext,
|
||||||
|
provider: AiProvider.OLLAMA,
|
||||||
|
runtimeTarget: target,
|
||||||
|
},
|
||||||
toolMemory,
|
toolMemory,
|
||||||
adapter,
|
adapter,
|
||||||
appendTargets: [messages],
|
appendTargets: [messages],
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
} from "openai/resources/responses/responses";
|
} from "openai/resources/responses/responses";
|
||||||
import {createOpenAiClient} from "./ai-runtime-target";
|
import {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,
|
||||||
@@ -29,23 +30,21 @@ import {
|
|||||||
showOpenAiGeneratedImage,
|
showOpenAiGeneratedImage,
|
||||||
ToolCallData,
|
ToolCallData,
|
||||||
ToolExecutionMemory,
|
ToolExecutionMemory,
|
||||||
errorMessage,
|
|
||||||
allToolSchemaNames
|
allToolSchemaNames
|
||||||
} from "./unified-ai-runner.shared";
|
} from "./unified-ai-runner.shared";
|
||||||
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
|
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
|
||||||
import {decideToolLoopContinuation} from "./tool-loop-control";
|
import {decideToolLoopContinuation} from "./tool-loop-control";
|
||||||
import {runToolLoopRounds} from "./tool-loop-runner";
|
import {runToolLoopRounds} from "./tool-loop-runner";
|
||||||
import {runSingleModelRequest} from "./model-call-stage";
|
import {runSingleModelRequest} from "./model-call-stage";
|
||||||
import {bot} from "../index";
|
import {ensureToolsSelected} from "./tool-mappers.js";
|
||||||
import fs from "node:fs";
|
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
|
||||||
import path from "node:path";
|
|
||||||
import {logError} from "../util/utils";
|
import {logError} from "../util/utils";
|
||||||
import {SendFileAttachmentResult, SendFileAttachmentResultSchema} from "./tools/files";
|
|
||||||
import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
|
import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
|
||||||
import {AiDownloadedFile} from "./telegram-attachments";
|
import {AiDownloadedFile} from "./telegram-attachments";
|
||||||
import {AiProvider} from "../model/ai-provider";
|
import {AiProvider} from "../model/ai-provider";
|
||||||
import {getProviderAdapter} from "./provider-adapters";
|
import {getProviderAdapter} from "./provider-adapters";
|
||||||
import {runToolRankStage} from "./tool-rank-stage";
|
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,
|
||||||
@@ -75,6 +74,7 @@ export async function runOpenAi(
|
|||||||
DEFAULT_AI_RESPONSE_LANGUAGE,
|
DEFAULT_AI_RESPONSE_LANGUAGE,
|
||||||
false,
|
false,
|
||||||
config.openAiChatTarget.systemPromptAdditions,
|
config.openAiChatTarget.systemPromptAdditions,
|
||||||
|
await buildUserMemoryPrompt(msg.from?.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
aiLog("info", "openai.run.start", {
|
aiLog("info", "openai.run.start", {
|
||||||
@@ -115,9 +115,13 @@ export async function runOpenAi(
|
|||||||
tools.unshift(fileSearchTool);
|
tools.unshift(fileSearchTool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tools.length ? tools : undefined;
|
const withMemory = ensureToolsSelected(availableTools, tools, MEMORY_TOOL_NAMES);
|
||||||
|
return withMemory.length ? withMemory : undefined;
|
||||||
})()
|
})()
|
||||||
: (filteredTools.length ? filteredTools : undefined);
|
: (() => {
|
||||||
|
const withMemory = ensureToolsSelected(availableTools, filteredTools, MEMORY_TOOL_NAMES);
|
||||||
|
return withMemory.length ? withMemory : undefined;
|
||||||
|
})();
|
||||||
|
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
const request: ResponseCreateParamsNonStreaming = {
|
const request: ResponseCreateParamsNonStreaming = {
|
||||||
@@ -187,7 +191,11 @@ export async function runOpenAi(
|
|||||||
userId: msg.from?.id,
|
userId: msg.from?.id,
|
||||||
toolCalls,
|
toolCalls,
|
||||||
streamMessage,
|
streamMessage,
|
||||||
toolContext,
|
toolContext: {
|
||||||
|
...toolContext,
|
||||||
|
provider: AiProvider.OPENAI,
|
||||||
|
runtimeTarget: config.openAiChatTarget,
|
||||||
|
},
|
||||||
toolMemory,
|
toolMemory,
|
||||||
adapter,
|
adapter,
|
||||||
appendTargets: [toolOutputs],
|
appendTargets: [toolOutputs],
|
||||||
@@ -397,7 +405,11 @@ export async function runOpenAi(
|
|||||||
userId: msg.from?.id,
|
userId: msg.from?.id,
|
||||||
toolCalls,
|
toolCalls,
|
||||||
streamMessage,
|
streamMessage,
|
||||||
toolContext,
|
toolContext: {
|
||||||
|
...toolContext,
|
||||||
|
provider: AiProvider.OPENAI,
|
||||||
|
runtimeTarget: config.openAiChatTarget,
|
||||||
|
},
|
||||||
toolMemory,
|
toolMemory,
|
||||||
adapter,
|
adapter,
|
||||||
appendTargets: [toolOutputs],
|
appendTargets: [toolOutputs],
|
||||||
@@ -504,72 +516,6 @@ async function cleanupOpenAiDocumentRag(openAi: OpenAI, vectorStoreId: string, f
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// function openAiResponseContentToText(content: string | readonly { text?: string; refusal?: string }[]): string {
|
// function openAiResponseContentToText(content: string | readonly { text?: string; refusal?: string }[]): string {
|
||||||
// if (typeof content === "string") return content;
|
// if (typeof content === "string") return content;
|
||||||
// if (!Array.isArray(content)) return "";
|
// if (!Array.isArray(content)) return "";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
|||||||
import type {BoundaryValue} from "../common/boundary-types";
|
import type {BoundaryValue} from "../common/boundary-types";
|
||||||
import {AiProvider} from "../model/ai-provider.js";
|
import {AiProvider} from "../model/ai-provider.js";
|
||||||
import {ToolRankerFallbackPolicy} from "../common/policies.js";
|
import {ToolRankerFallbackPolicy} from "../common/policies.js";
|
||||||
import {Environment} from "../common/environment.js";
|
import {Environment, type OpenAiBackend} from "../common/environment.js";
|
||||||
import {delay, logError, replyToMessage} from "../util/utils.js";
|
import {delay, logError, replyToMessage} from "../util/utils.js";
|
||||||
import {MessageStore} from "../common/message-store.js";
|
import {MessageStore} from "../common/message-store.js";
|
||||||
import type {OpenAiResponseTool} from "./tool-mappers.js";
|
import type {OpenAiResponseTool} from "./tool-mappers.js";
|
||||||
@@ -274,6 +274,7 @@ export type RuntimeConfigSnapshot = {
|
|||||||
openAiChatTarget: AiRuntimeTarget;
|
openAiChatTarget: AiRuntimeTarget;
|
||||||
openAiImageTarget: AiRuntimeTarget;
|
openAiImageTarget: AiRuntimeTarget;
|
||||||
openAiToolRankerTarget?: AiRuntimeTarget;
|
openAiToolRankerTarget?: AiRuntimeTarget;
|
||||||
|
openAiBackend: OpenAiBackend;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
|
export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
|
||||||
@@ -307,9 +308,14 @@ export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
|
|||||||
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"),
|
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"}));
|
||||||
@@ -382,11 +388,13 @@ export function buildSystemInstruction(
|
|||||||
responseLanguage: UserAiResponseLanguage,
|
responseLanguage: UserAiResponseLanguage,
|
||||||
includePythonToolPrompt: boolean,
|
includePythonToolPrompt: boolean,
|
||||||
additions?: string | null,
|
additions?: string | null,
|
||||||
|
memoryInstruction?: string | null,
|
||||||
): string {
|
): string {
|
||||||
return [
|
return [
|
||||||
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
|
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
|
||||||
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
|
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
|
||||||
additions?.trim() ? additions.trim() : 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");
|
||||||
}
|
}
|
||||||
@@ -1117,19 +1125,31 @@ export async function executeTool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toolResourceKeys(toolCall: ToolCallData): string[] {
|
export function toolResourceKeys(toolCall: ToolCallData, userId?: number | undefined | null): 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":
|
||||||
@@ -1162,7 +1182,7 @@ export async function executeScheduledTool(
|
|||||||
message: TelegramStreamMessage,
|
message: TelegramStreamMessage,
|
||||||
context: ToolRuntimeContext,
|
context: ToolRuntimeContext,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const keys = toolResourceKeys(toolCall);
|
const keys = toolResourceKeys(toolCall, userId);
|
||||||
if (!keys.length) return executeTool(userId, toolCall, message, context);
|
if (!keys.length) return executeTool(userId, toolCall, message, context);
|
||||||
return runWithToolLocks(keys, () => executeTool(userId, toolCall, message, context));
|
return runWithToolLocks(keys, () => executeTool(userId, toolCall, message, context));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ChatCompletionMessageParam} from "openai/resources/chat/completions";
|
import type {ChatCompletionCreateParamsNonStreaming, ChatCompletionMessageParam} from "openai/resources/chat/completions";
|
||||||
import {ChatRequest} from "ollama";
|
import {ChatRequest} from "ollama";
|
||||||
import {BoundaryValue} from "../common/boundary-types.js";
|
import {BoundaryValue} from "../common/boundary-types.js";
|
||||||
import {ToolRankerFallbackPolicy} from "../common/policies.js";
|
import {ToolRankerFallbackPolicy} from "../common/policies.js";
|
||||||
@@ -107,7 +107,7 @@ export class ToolRanker {
|
|||||||
target: aiLogProviderTarget(target),
|
target: aiLogProviderTarget(target),
|
||||||
fallbackTarget: aiLogProviderTarget(mainModelTarget),
|
fallbackTarget: aiLogProviderTarget(mainModelTarget),
|
||||||
duration: aiLogDuration(startedAt),
|
duration: aiLogDuration(startedAt),
|
||||||
error: failureMessage,
|
errorSummary: failureMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fallbackRanker = buildToolRankerPrompt(
|
const fallbackRanker = buildToolRankerPrompt(
|
||||||
@@ -142,7 +142,7 @@ export class ToolRanker {
|
|||||||
target: aiLogProviderTarget(target),
|
target: aiLogProviderTarget(target),
|
||||||
fallbackTarget: aiLogProviderTarget(mainModelTarget),
|
fallbackTarget: aiLogProviderTarget(mainModelTarget),
|
||||||
duration: aiLogDuration(startedAt),
|
duration: aiLogDuration(startedAt),
|
||||||
error: fallbackErrorMessage,
|
errorSummary: fallbackErrorMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
failureMessage = fallbackErrorMessage;
|
failureMessage = fallbackErrorMessage;
|
||||||
@@ -155,7 +155,7 @@ export class ToolRanker {
|
|||||||
target: aiLogProviderTarget(target),
|
target: aiLogProviderTarget(target),
|
||||||
fallbackPolicy,
|
fallbackPolicy,
|
||||||
duration: aiLogDuration(startedAt),
|
duration: aiLogDuration(startedAt),
|
||||||
error: failureMessage,
|
errorSummary: failureMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
return resolveToolRankerFallbackSelection({
|
return resolveToolRankerFallbackSelection({
|
||||||
@@ -227,12 +227,19 @@ export class ToolRanker {
|
|||||||
{role: "user", content: userQuery},
|
{role: "user", content: userQuery},
|
||||||
] satisfies ChatCompletionMessageParam[];
|
] satisfies ChatCompletionMessageParam[];
|
||||||
|
|
||||||
// gpt-5 family ranker targets reject temperature=0; use the model default instead.
|
// OpenAI-compatible servers often reject `response_format`, so keep JSON mode
|
||||||
const response = await openAi.chat.completions.create({
|
// only for official OpenAI endpoints.
|
||||||
|
const request: ChatCompletionCreateParamsNonStreaming = {
|
||||||
model: target.model,
|
model: target.model,
|
||||||
messages,
|
messages,
|
||||||
response_format: {type: "json_object"},
|
};
|
||||||
});
|
|
||||||
|
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() ?? "";
|
return response.choices[0]?.message?.content?.trim() ?? "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import type {ToolCallData} from "../ai/unified-ai-runner.js";
|
|||||||
import {PYTHON_INTERPRETER_TOOL_NAME} from "../ai/tools/python-interpretator.js";
|
import {PYTHON_INTERPRETER_TOOL_NAME} from "../ai/tools/python-interpretator.js";
|
||||||
import {Localization, type LocalizationParams} from "./localization.js";
|
import {Localization, type LocalizationParams} from "./localization.js";
|
||||||
|
|
||||||
|
export const OpenAiBackendModes = {
|
||||||
|
OFFICIAL: "official",
|
||||||
|
COMPATIBLE: "compatible",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type OpenAiBackend = typeof OpenAiBackendModes[keyof typeof OpenAiBackendModes];
|
||||||
|
|
||||||
function parseBooleanLike(value: string): boolean {
|
function parseBooleanLike(value: string): boolean {
|
||||||
const normalized = value.trim().toLowerCase();
|
const normalized = value.trim().toLowerCase();
|
||||||
return ["true", "t", "y", "1"].includes(normalized);
|
return ["true", "t", "y", "1"].includes(normalized);
|
||||||
@@ -214,6 +221,10 @@ const RuntimeEnvSchema = z.object({
|
|||||||
SEND_TIME_TOOK: optionalBooleanSchema,
|
SEND_TIME_TOOK: optionalBooleanSchema,
|
||||||
|
|
||||||
ENABLE_PYTHON_INTERPRETER: optionalBooleanSchema,
|
ENABLE_PYTHON_INTERPRETER: optionalBooleanSchema,
|
||||||
|
DISABLE_LOCAL_TOOLS: optionalBooleanSchema,
|
||||||
|
LOCAL_TOOL_ALLOWLIST: optionalStringSchema,
|
||||||
|
LOCAL_TOOL_DENYLIST: optionalStringSchema,
|
||||||
|
MCP_SERVERS: optionalStringSchema,
|
||||||
|
|
||||||
OLLAMA_API_KEY: optionalStringSchema,
|
OLLAMA_API_KEY: optionalStringSchema,
|
||||||
OLLAMA_ADDRESS: optionalStringSchema,
|
OLLAMA_ADDRESS: optionalStringSchema,
|
||||||
@@ -241,6 +252,10 @@ const RuntimeEnvSchema = z.object({
|
|||||||
|
|
||||||
OPENAI_BASE_URL: optionalStringSchema,
|
OPENAI_BASE_URL: optionalStringSchema,
|
||||||
OPENAI_API_KEY: optionalStringSchema,
|
OPENAI_API_KEY: optionalStringSchema,
|
||||||
|
OPENAI_BACKEND: enumWithDefaultSchema(
|
||||||
|
OpenAiBackendModes,
|
||||||
|
OpenAiBackendModes.OFFICIAL,
|
||||||
|
),
|
||||||
OPENAI_MODEL: stringWithDefaultSchema("gpt-4.1-nano"),
|
OPENAI_MODEL: stringWithDefaultSchema("gpt-4.1-nano"),
|
||||||
OPENAI_IMAGE_MODEL: stringWithDefaultSchema("gpt-image-1-mini"),
|
OPENAI_IMAGE_MODEL: stringWithDefaultSchema("gpt-image-1-mini"),
|
||||||
OPENAI_TRANSCRIPTION_MODEL: stringWithDefaultSchema("gpt-4o-mini-transcribe"),
|
OPENAI_TRANSCRIPTION_MODEL: stringWithDefaultSchema("gpt-4o-mini-transcribe"),
|
||||||
@@ -308,6 +323,10 @@ export class Environment {
|
|||||||
static SEND_TIME_TOOK: boolean = false;
|
static SEND_TIME_TOOK: boolean = false;
|
||||||
|
|
||||||
static ENABLE_PYTHON_INTERPRETER: boolean = false;
|
static ENABLE_PYTHON_INTERPRETER: boolean = false;
|
||||||
|
static DISABLE_LOCAL_TOOLS: boolean = false;
|
||||||
|
static LOCAL_TOOL_ALLOWLIST?: string;
|
||||||
|
static LOCAL_TOOL_DENYLIST?: string;
|
||||||
|
static MCP_SERVERS?: string;
|
||||||
|
|
||||||
static OLLAMA_API_KEY?: string;
|
static OLLAMA_API_KEY?: string;
|
||||||
static OLLAMA_ADDRESS?: string;
|
static OLLAMA_ADDRESS?: string;
|
||||||
@@ -335,6 +354,7 @@ export class Environment {
|
|||||||
|
|
||||||
static OPENAI_BASE_URL?: string;
|
static OPENAI_BASE_URL?: string;
|
||||||
static OPENAI_API_KEY?: string;
|
static OPENAI_API_KEY?: string;
|
||||||
|
static OPENAI_BACKEND: OpenAiBackend = OpenAiBackendModes.OFFICIAL;
|
||||||
static OPENAI_MODEL: string = "";
|
static OPENAI_MODEL: string = "";
|
||||||
static OPENAI_IMAGE_MODEL: string = "";
|
static OPENAI_IMAGE_MODEL: string = "";
|
||||||
static OPENAI_TRANSCRIPTION_MODEL: string = "";
|
static OPENAI_TRANSCRIPTION_MODEL: string = "";
|
||||||
@@ -1842,6 +1862,10 @@ export class Environment {
|
|||||||
Environment.SEND_TIME_TOOK = env.SEND_TIME_TOOK ?? false;
|
Environment.SEND_TIME_TOOK = env.SEND_TIME_TOOK ?? false;
|
||||||
|
|
||||||
Environment.ENABLE_PYTHON_INTERPRETER = env.ENABLE_PYTHON_INTERPRETER ?? false;
|
Environment.ENABLE_PYTHON_INTERPRETER = env.ENABLE_PYTHON_INTERPRETER ?? false;
|
||||||
|
Environment.DISABLE_LOCAL_TOOLS = env.DISABLE_LOCAL_TOOLS ?? false;
|
||||||
|
Environment.LOCAL_TOOL_ALLOWLIST = env.LOCAL_TOOL_ALLOWLIST;
|
||||||
|
Environment.LOCAL_TOOL_DENYLIST = env.LOCAL_TOOL_DENYLIST;
|
||||||
|
Environment.MCP_SERVERS = env.MCP_SERVERS;
|
||||||
|
|
||||||
Environment.OLLAMA_API_KEY = env.OLLAMA_API_KEY;
|
Environment.OLLAMA_API_KEY = env.OLLAMA_API_KEY;
|
||||||
Environment.OLLAMA_ADDRESS = env.OLLAMA_ADDRESS;
|
Environment.OLLAMA_ADDRESS = env.OLLAMA_ADDRESS;
|
||||||
@@ -1869,6 +1893,7 @@ export class Environment {
|
|||||||
|
|
||||||
Environment.OPENAI_BASE_URL = env.OPENAI_BASE_URL;
|
Environment.OPENAI_BASE_URL = env.OPENAI_BASE_URL;
|
||||||
Environment.OPENAI_API_KEY = env.OPENAI_API_KEY;
|
Environment.OPENAI_API_KEY = env.OPENAI_API_KEY;
|
||||||
|
Environment.OPENAI_BACKEND = env.OPENAI_BACKEND;
|
||||||
Environment.OPENAI_MODEL = env.OPENAI_MODEL;
|
Environment.OPENAI_MODEL = env.OPENAI_MODEL;
|
||||||
Environment.OPENAI_IMAGE_MODEL = env.OPENAI_IMAGE_MODEL;
|
Environment.OPENAI_IMAGE_MODEL = env.OPENAI_IMAGE_MODEL;
|
||||||
Environment.OPENAI_TRANSCRIPTION_MODEL = env.OPENAI_TRANSCRIPTION_MODEL;
|
Environment.OPENAI_TRANSCRIPTION_MODEL = env.OPENAI_TRANSCRIPTION_MODEL;
|
||||||
@@ -2069,6 +2094,10 @@ export class Environment {
|
|||||||
this.OPENAI_API_KEY = newAIApiKey;
|
this.OPENAI_API_KEY = newAIApiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static setOpenAIBackend(newBackend: OpenAiBackend): void {
|
||||||
|
this.OPENAI_BACKEND = newBackend;
|
||||||
|
}
|
||||||
|
|
||||||
static setOpenAIModel(newModel: string): void {
|
static setOpenAIModel(newModel: string): void {
|
||||||
this.OPENAI_MODEL = newModel;
|
this.OPENAI_MODEL = newModel;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2055,7 +2055,24 @@ export class DatabaseManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async migrateLegacyNormalizedTables(): Promise<void> {
|
private static async migrateLegacyNormalizedTables(): Promise<void> {
|
||||||
const messages = await DatabaseManager.getAllMessages();
|
// Do not call getAllMessages() here: it awaits DatabaseManager.ready, which
|
||||||
|
// is the promise currently waiting on ensureSchema(). That creates a
|
||||||
|
// self-deadlock during startup migrations.
|
||||||
|
const messages = await DatabaseManager.query<MessageDbRow>(`
|
||||||
|
SELECT
|
||||||
|
"id",
|
||||||
|
"chatId",
|
||||||
|
"replyToMessageId",
|
||||||
|
"fromId",
|
||||||
|
"text",
|
||||||
|
"quoteText",
|
||||||
|
"date",
|
||||||
|
"deletedByBotAt",
|
||||||
|
"attachments",
|
||||||
|
"pipelineAudit"
|
||||||
|
FROM "messages"
|
||||||
|
ORDER BY "chatId", "id"
|
||||||
|
`);
|
||||||
const attachments = messages.flatMap(message => DatabaseManager.attachmentRowsFromMessageRow(message));
|
const attachments = messages.flatMap(message => DatabaseManager.attachmentRowsFromMessageRow(message));
|
||||||
const artifacts = messages.flatMap(message => DatabaseManager.artifactRowsFromMessageRow(message));
|
const artifacts = messages.flatMap(message => DatabaseManager.artifactRowsFromMessageRow(message));
|
||||||
const requestAudits = messages.flatMap(message => DatabaseManager.requestAuditRowsFromMessageRow(message));
|
const requestAudits = messages.flatMap(message => DatabaseManager.requestAuditRowsFromMessageRow(message));
|
||||||
|
|||||||
+10
-1
@@ -79,6 +79,7 @@ import {AIAudit} from "./commands/ai-audit.js";
|
|||||||
import {AIMetrics} from "./commands/ai-metrics.js";
|
import {AIMetrics} from "./commands/ai-metrics.js";
|
||||||
import {AIRequests} from "./commands/ai-requests.js";
|
import {AIRequests} from "./commands/ai-requests.js";
|
||||||
import {cleanupStaleRagProviderState} from "./ai/rag-retention.js";
|
import {cleanupStaleRagProviderState} from "./ai/rag-retention.js";
|
||||||
|
import {initializeMcpTools, shutdownMcpTools} from "./ai/mcp/mcp-registry.js";
|
||||||
|
|
||||||
process.setUncaughtExceptionCaptureCallback(logError);
|
process.setUncaughtExceptionCaptureCallback(logError);
|
||||||
|
|
||||||
@@ -193,6 +194,7 @@ export const filesDir = path.join(Environment.DATA_PATH, "files");
|
|||||||
export const NOTES_HEADER = "## Notes\n";
|
export const NOTES_HEADER = "## Notes\n";
|
||||||
export const notesDir = path.join(Environment.DATA_PATH, "notes");
|
export const notesDir = path.join(Environment.DATA_PATH, "notes");
|
||||||
export const notesRootFile = path.join(notesDir, "index.md");
|
export const notesRootFile = path.join(notesDir, "index.md");
|
||||||
|
export const memoryDir = path.join(Environment.DATA_PATH, "memory");
|
||||||
|
|
||||||
const logger = appLogger.child("main");
|
const logger = appLogger.child("main");
|
||||||
|
|
||||||
@@ -234,6 +236,11 @@ export async function shutdown(signal: NodeJS.Signals | "manual") {
|
|||||||
await bot.stopPolling();
|
await bot.stopPolling();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error instanceof Error ? error : String(error));
|
logError(error instanceof Error ? error : String(error));
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await shutdownMcpTools();
|
||||||
|
} catch (error) {
|
||||||
|
logError(error instanceof Error ? error : String(error));
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await DatabaseManager.close();
|
await DatabaseManager.close();
|
||||||
@@ -242,6 +249,7 @@ export async function shutdown(signal: NodeJS.Signals | "manual") {
|
|||||||
}
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -255,7 +263,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await measureStartupStep("environment.load", () => Environment.load());
|
await measureStartupStep("environment.load", () => Environment.load());
|
||||||
const dirsToCheck = [cacheDir, photoDir, photoGenDir, documentDir, audioDir, videoDir, videoNotesDir, videoTempDir, notesDir, filesDir];
|
const dirsToCheck = [cacheDir, photoDir, photoGenDir, documentDir, audioDir, videoDir, videoNotesDir, videoTempDir, notesDir, memoryDir, filesDir];
|
||||||
await measureStartupStep("prepare_directories", () => {
|
await measureStartupStep("prepare_directories", () => {
|
||||||
const created: string[] = [];
|
const created: string[] = [];
|
||||||
for (const dir of dirsToCheck) {
|
for (const dir of dirsToCheck) {
|
||||||
@@ -280,6 +288,7 @@ async function main() {
|
|||||||
|
|
||||||
await measureStartupStep("cleanup_internal_artifacts", () => cleanupInternalArtifactCache(), () => ({retentionDays: 14}));
|
await measureStartupStep("cleanup_internal_artifacts", () => cleanupInternalArtifactCache(), () => ({retentionDays: 14}));
|
||||||
await measureStartupStep("cleanup_stale_rag_provider_state", () => cleanupStaleRagProviderState(), () => ({retentionDays: 14}));
|
await measureStartupStep("cleanup_stale_rag_provider_state", () => cleanupStaleRagProviderState(), () => ({retentionDays: 14}));
|
||||||
|
await measureStartupStep("mcp.initialize", () => initializeMcpTools());
|
||||||
await measureStartupStep("observability.snapshot", async () => {
|
await measureStartupStep("observability.snapshot", async () => {
|
||||||
const [aiRequests, attachments, artifacts, requestAudits] = await Promise.all([
|
const [aiRequests, attachments, artifacts, requestAudits] = await Promise.all([
|
||||||
DatabaseManager.getAllAiRequests(),
|
DatabaseManager.getAllAiRequests(),
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
const {Environment} = await import("../dist/common/environment.js");
|
||||||
|
|
||||||
|
test("openai backend defaults to official", () => {
|
||||||
|
assert.equal(Environment.OPENAI_BACKEND, "official");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("openai backend setter updates runtime config", () => {
|
||||||
|
Environment.setOpenAIBackend("compatible");
|
||||||
|
assert.equal(Environment.OPENAI_BACKEND, "compatible");
|
||||||
|
Environment.setOpenAIBackend("official");
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {OpenAI} from "openai";
|
||||||
|
|
||||||
|
const {extractOpenAiChatToolCalls} = await import("../dist/ai/provider-adapter-contract.js");
|
||||||
|
|
||||||
|
const baseURL = process.env.OPENAI_COMPATIBLE_TEST_BASE_URL;
|
||||||
|
const model = process.env.OPENAI_COMPATIBLE_TEST_MODEL;
|
||||||
|
const apiKey = process.env.OPENAI_COMPATIBLE_TEST_API_KEY ?? process.env.OPENAI_API_KEY ?? "test";
|
||||||
|
|
||||||
|
test("openai-compatible chat.completions tool loop works on a real server", {skip: !baseURL || !model}, async () => {
|
||||||
|
const client = new OpenAI({baseURL, apiKey});
|
||||||
|
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model,
|
||||||
|
temperature: 0,
|
||||||
|
messages: [
|
||||||
|
{role: "system", content: "You must call the ping tool exactly once. Do not answer in plain text."},
|
||||||
|
{role: "user", content: "ping"},
|
||||||
|
],
|
||||||
|
tools: [{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "ping",
|
||||||
|
description: "Return a ping token.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
tool_choice: {
|
||||||
|
type: "function",
|
||||||
|
function: {name: "ping"},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = extractOpenAiChatToolCalls(response);
|
||||||
|
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].name, "ping");
|
||||||
|
});
|
||||||
@@ -5,6 +5,11 @@ const {
|
|||||||
extractOpenAiToolCalls,
|
extractOpenAiToolCalls,
|
||||||
extractOpenAiStreamingToolCalls,
|
extractOpenAiStreamingToolCalls,
|
||||||
extractOpenAiTextDelta,
|
extractOpenAiTextDelta,
|
||||||
|
extractOpenAiChatToolCalls,
|
||||||
|
extractOpenAiChatStreamingToolCalls,
|
||||||
|
extractOpenAiChatTextDelta,
|
||||||
|
mergeToolCallChunks,
|
||||||
|
normalizeStreamingTextDelta,
|
||||||
extractMistralToolCalls,
|
extractMistralToolCalls,
|
||||||
extractMistralTextDelta,
|
extractMistralTextDelta,
|
||||||
extractOllamaToolCalls,
|
extractOllamaToolCalls,
|
||||||
@@ -42,6 +47,62 @@ test("openai contract extracts text delta and function calls", () => {
|
|||||||
assert.equal(streamed[0].name, "search_files");
|
assert.equal(streamed[0].name, "search_files");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("openai chat contract extracts text delta and tool calls", () => {
|
||||||
|
assert.equal(extractOpenAiChatTextDelta({choices: [{delta: {content: "hello chat"}}]}), "hello chat");
|
||||||
|
assert.equal(normalizeStreamingTextDelta("hel", "hello"), "lo");
|
||||||
|
assert.equal(normalizeStreamingTextDelta("hel", "lo"), "lo");
|
||||||
|
|
||||||
|
const calls = extractOpenAiChatToolCalls({
|
||||||
|
choices: [{
|
||||||
|
message: {
|
||||||
|
tool_calls: [{
|
||||||
|
id: "chat-1",
|
||||||
|
function: {
|
||||||
|
name: "read_user_info",
|
||||||
|
arguments: "{\"userId\":123}",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].id, "chat-1");
|
||||||
|
assert.equal(calls[0].name, "read_user_info");
|
||||||
|
|
||||||
|
const streamed = extractOpenAiChatStreamingToolCalls({
|
||||||
|
choices: [{
|
||||||
|
delta: {
|
||||||
|
tool_calls: [{
|
||||||
|
index: 0,
|
||||||
|
id: "chat-2",
|
||||||
|
function: {
|
||||||
|
name: "write_note",
|
||||||
|
arguments: "{\"text\":",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(streamed.length, 1);
|
||||||
|
assert.equal(streamed[0].id, "chat-2");
|
||||||
|
assert.equal(streamed[0].name, "write_note");
|
||||||
|
assert.equal(streamed[0].argumentsText, "{\"text\":");
|
||||||
|
|
||||||
|
const merged = mergeToolCallChunks([
|
||||||
|
{id: "chat-2", name: "", argumentsText: "{\"text\":"},
|
||||||
|
], [{
|
||||||
|
id: "chat-2",
|
||||||
|
name: "write_note",
|
||||||
|
argumentsText: "\"hello\"}",
|
||||||
|
}]);
|
||||||
|
|
||||||
|
assert.equal(merged.length, 1);
|
||||||
|
assert.equal(merged[0].name, "write_note");
|
||||||
|
assert.equal(merged[0].argumentsText, "{\"text\":\"hello\"}");
|
||||||
|
});
|
||||||
|
|
||||||
test("mistral contract extracts content and tool calls", () => {
|
test("mistral contract extracts content and tool calls", () => {
|
||||||
assert.equal(extractMistralTextDelta({
|
assert.equal(extractMistralTextDelta({
|
||||||
content: [{text: "hello"}, {text: " world"}],
|
content: [{text: "hello"}, {text: " world"}],
|
||||||
|
|||||||
@@ -86,6 +86,19 @@ test("prompt includes search files routing example for usage search", () => {
|
|||||||
assert.ok(prompt.includes(JSON.stringify({toolNames: ["search_files"]})));
|
assert.ok(prompt.includes(JSON.stringify({toolNames: ["search_files"]})));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("prompt includes memory routing examples for remember requests", () => {
|
||||||
|
const prompt = promptFor("no_tool", "read_user_info", "add_user_info", "remove_user_info", "replace_user_info", "delete_user_info");
|
||||||
|
|
||||||
|
assert.ok(prompt.includes("что ты помнишь обо мне?"));
|
||||||
|
assert.ok(prompt.includes("запомни, что меня зовут Иван"));
|
||||||
|
assert.ok(prompt.includes("забудь, что я люблю кофе"));
|
||||||
|
assert.ok(prompt.includes("забудь всё обо мне и запиши только это"));
|
||||||
|
assert.ok(prompt.includes("удали всю память обо мне"));
|
||||||
|
assert.ok(prompt.includes("inspect remembered user info -> read_user_info"));
|
||||||
|
assert.ok(prompt.includes("remember a new user fact -> add_user_info"));
|
||||||
|
assert.ok(prompt.includes(JSON.stringify({toolNames: ["add_user_info"]})));
|
||||||
|
});
|
||||||
|
|
||||||
test("prompt includes edit file patch routing example for targeted edits", () => {
|
test("prompt includes edit file patch routing example for targeted edits", () => {
|
||||||
const prompt = promptFor("no_tool", "edit_file_patch");
|
const prompt = promptFor("no_tool", "edit_file_patch");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const {Environment} = await import("../dist/common/environment.js");
|
||||||
|
const {
|
||||||
|
buildUserMemoryPrompt,
|
||||||
|
compressMemoryWithFallback,
|
||||||
|
deleteUserMemory,
|
||||||
|
getMemoryFilePath,
|
||||||
|
readUserMemory,
|
||||||
|
updateUserMemory,
|
||||||
|
} = await import("../dist/ai/tools/user-memory.js");
|
||||||
|
const {AiProvider} = await import("../dist/model/ai-provider.js");
|
||||||
|
|
||||||
|
function makeTempDataPath() {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), "tg-chat-bot-memory-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function withEnv(vars, fn) {
|
||||||
|
const snapshot = new Map();
|
||||||
|
for (const [key, value] of Object.entries(vars)) {
|
||||||
|
snapshot.set(key, process.env[key]);
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
} else {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(fn()).finally(() => {
|
||||||
|
for (const [key, value] of snapshot.entries()) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
} else {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("memory storage supports append replace and remove", async () => {
|
||||||
|
const oldDataPath = Environment.DATA_PATH;
|
||||||
|
Environment.DATA_PATH = makeTempDataPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userId = 475823381;
|
||||||
|
|
||||||
|
let result = await updateUserMemory({
|
||||||
|
userId,
|
||||||
|
scope: "user",
|
||||||
|
action: "replace",
|
||||||
|
content: "# Profile\nLikes tea",
|
||||||
|
});
|
||||||
|
assert.equal(result.success, true);
|
||||||
|
|
||||||
|
result = await updateUserMemory({
|
||||||
|
userId,
|
||||||
|
scope: "user",
|
||||||
|
action: "add",
|
||||||
|
content: "Prefers concise answers",
|
||||||
|
});
|
||||||
|
assert.equal(result.success, true);
|
||||||
|
assert.match(result.content, /Likes tea/);
|
||||||
|
assert.match(result.content, /Prefers concise answers/);
|
||||||
|
|
||||||
|
result = await updateUserMemory({
|
||||||
|
userId,
|
||||||
|
scope: "user",
|
||||||
|
action: "remove",
|
||||||
|
content: "Prefers concise answers",
|
||||||
|
});
|
||||||
|
assert.equal(result.success, true);
|
||||||
|
assert.doesNotMatch(result.content, /Prefers concise answers/);
|
||||||
|
|
||||||
|
const readback = await readUserMemory(userId, "user");
|
||||||
|
assert.equal(readback.success, true);
|
||||||
|
assert.equal(readback.filePath, getMemoryFilePath(userId, "user"));
|
||||||
|
assert.match(readback.content, /Likes tea/);
|
||||||
|
} finally {
|
||||||
|
Environment.DATA_PATH = oldDataPath;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("memory delete removes the file", async () => {
|
||||||
|
const oldDataPath = Environment.DATA_PATH;
|
||||||
|
Environment.DATA_PATH = makeTempDataPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userId = 999;
|
||||||
|
await updateUserMemory({userId, scope: "user", action: "replace", content: "hello"});
|
||||||
|
const deleted = await deleteUserMemory(userId, "user");
|
||||||
|
assert.equal(deleted.success, true);
|
||||||
|
const readback = await readUserMemory(userId, "user");
|
||||||
|
assert.equal(readback.success, true);
|
||||||
|
assert.equal(readback.content, "");
|
||||||
|
} finally {
|
||||||
|
Environment.DATA_PATH = oldDataPath;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("memory prompt combines system and user files", async () => {
|
||||||
|
const oldDataPath = Environment.DATA_PATH;
|
||||||
|
Environment.DATA_PATH = makeTempDataPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userId = 1234;
|
||||||
|
|
||||||
|
await updateUserMemory({
|
||||||
|
userId,
|
||||||
|
scope: "system",
|
||||||
|
action: "replace",
|
||||||
|
content: "Ты зовешься Евлампий.",
|
||||||
|
});
|
||||||
|
await updateUserMemory({
|
||||||
|
userId,
|
||||||
|
scope: "user",
|
||||||
|
action: "replace",
|
||||||
|
content: "Пользователь любит короткие ответы.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompt = await buildUserMemoryPrompt(userId);
|
||||||
|
assert(prompt);
|
||||||
|
assert.equal(prompt?.includes("## Assistant memory (system.md)"), true);
|
||||||
|
assert.equal(prompt?.includes("This is information about the assistant and its behavior."), true);
|
||||||
|
assert.equal(prompt?.includes("## User memory (user.md)"), true);
|
||||||
|
assert.equal(prompt?.includes("This is information about the user."), true);
|
||||||
|
assert(prompt.indexOf("## Assistant memory (system.md)") < prompt.indexOf("## User memory (user.md)"));
|
||||||
|
} finally {
|
||||||
|
Environment.DATA_PATH = oldDataPath;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("memory compression falls back to current target when explicit target fails", async () => {
|
||||||
|
await withEnv({
|
||||||
|
OLLAMA_MEMORY_COMPRESS_MODEL: "memory-compress-model",
|
||||||
|
OLLAMA_CHAT_MODEL: "chat-model",
|
||||||
|
}, async () => {
|
||||||
|
const calls = [];
|
||||||
|
const result = await compressMemoryWithFallback(
|
||||||
|
{
|
||||||
|
provider: AiProvider.OLLAMA,
|
||||||
|
currentTarget: {
|
||||||
|
provider: AiProvider.OLLAMA,
|
||||||
|
purpose: "chat",
|
||||||
|
model: "chat-model",
|
||||||
|
},
|
||||||
|
scope: "system",
|
||||||
|
currentText: "x".repeat(1200),
|
||||||
|
limit: 1000,
|
||||||
|
},
|
||||||
|
async ({target}) => {
|
||||||
|
calls.push(target.model);
|
||||||
|
if (target.model === "memory-compress-model") {
|
||||||
|
throw new Error("boom");
|
||||||
|
}
|
||||||
|
return "short summary";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ["memory-compress-model", "chat-model"]);
|
||||||
|
assert.equal(result.content, "short summary");
|
||||||
|
assert.equal(result.compressed, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("memory compression uses current target when no separate target exists", async () => {
|
||||||
|
await withEnv({
|
||||||
|
OLLAMA_MEMORY_COMPRESS_MODEL: undefined,
|
||||||
|
OLLAMA_CHAT_MODEL: "chat-model",
|
||||||
|
}, async () => {
|
||||||
|
const calls = [];
|
||||||
|
const result = await compressMemoryWithFallback(
|
||||||
|
{
|
||||||
|
provider: AiProvider.OLLAMA,
|
||||||
|
currentTarget: {
|
||||||
|
provider: AiProvider.OLLAMA,
|
||||||
|
purpose: "chat",
|
||||||
|
model: "chat-model",
|
||||||
|
},
|
||||||
|
scope: "user",
|
||||||
|
currentText: "x".repeat(1200),
|
||||||
|
limit: 1000,
|
||||||
|
},
|
||||||
|
async ({target}) => {
|
||||||
|
calls.push(target.model);
|
||||||
|
return "summary";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ["chat-model"]);
|
||||||
|
assert.equal(result.content, "summary");
|
||||||
|
assert.equal(result.compressed, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user