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_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)
|
||||
SYSTEM_PROMPT=
|
||||
|
||||
@@ -91,6 +103,10 @@ OLLAMA_MAX_CONCURRENT_REQUESTS=1
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=
|
||||
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_IMAGE_MODEL=gpt-image-1-mini
|
||||
OPENAI_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe
|
||||
@@ -99,6 +115,14 @@ OPENAI_TTS_VOICE=alloy
|
||||
OPENAI_TTS_INSTRUCTIONS=
|
||||
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
|
||||
# Pattern:
|
||||
# <PROVIDER>_<CAPABILITY>_MODEL=
|
||||
@@ -113,6 +137,7 @@ OPENAI_MAX_CONCURRENT_REQUESTS=3
|
||||
# OLLAMA_ADDRESS or OLLAMA_BASE_URL.
|
||||
# Capability aliases are also supported: IMAGE, THINK, RAG, EMBEDDING,
|
||||
# TRANSCRIPTION, STT, TTS.
|
||||
# Backend override: OPENAI_BACKEND=official|compatible.
|
||||
#
|
||||
# Examples:
|
||||
# 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
|
||||
cp .env.example .env
|
||||
# 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 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.
|
||||
`/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`:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
"emoji-regex": "^10.6.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"ollama": "^0.6.3",
|
||||
"openai": "^6.37.0",
|
||||
"pg": "^8.20.0",
|
||||
"openai": "^6.38.0",
|
||||
"pg": "^8.21.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.34.5",
|
||||
"systeminformation": "^5.31.6",
|
||||
@@ -27,12 +27,12 @@
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"eslint": "^9.39.4",
|
||||
"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/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=="],
|
||||
|
||||
@@ -187,25 +187,25 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -399,7 +399,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -415,15 +415,15 @@
|
||||
|
||||
"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-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=="],
|
||||
|
||||
@@ -495,7 +495,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -551,12 +551,16 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"ollama": "^0.6.3",
|
||||
"openai": "^6.37.0",
|
||||
"pg": "^8.20.0",
|
||||
"openai": "^6.38.0",
|
||||
"pg": "^8.21.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.34.5",
|
||||
"systeminformation": "^5.31.6",
|
||||
@@ -30,12 +30,12 @@
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"eslint": "^9.39.4",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.3"
|
||||
"typescript-eslint": "^8.59.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
@@ -1246,9 +1246,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
|
||||
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
@@ -1286,17 +1286,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
|
||||
"integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz",
|
||||
"integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"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",
|
||||
"@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"
|
||||
@@ -1309,7 +1309,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
@@ -1325,16 +1325,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz",
|
||||
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz",
|
||||
"integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"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",
|
||||
"@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"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1350,14 +1350,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz",
|
||||
"integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz",
|
||||
"integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.3",
|
||||
"@typescript-eslint/types": "^8.59.3",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.4",
|
||||
"@typescript-eslint/types": "^8.59.4",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1372,14 +1372,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz",
|
||||
"integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz",
|
||||
"integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/visitor-keys": "8.59.3"
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/visitor-keys": "8.59.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1390,9 +1390,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz",
|
||||
"integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz",
|
||||
"integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1407,15 +1407,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz",
|
||||
"integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz",
|
||||
"integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/typescript-estree": "8.59.3",
|
||||
"@typescript-eslint/utils": "8.59.3",
|
||||
"@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"
|
||||
},
|
||||
@@ -1432,9 +1432,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz",
|
||||
"integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz",
|
||||
"integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1446,16 +1446,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz",
|
||||
"integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz",
|
||||
"integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"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",
|
||||
"@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",
|
||||
@@ -1513,16 +1513,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz",
|
||||
"integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz",
|
||||
"integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"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"
|
||||
"@typescript-eslint/scope-manager": "8.59.4",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"@typescript-eslint/typescript-estree": "8.59.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1537,13 +1537,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz",
|
||||
"integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz",
|
||||
"integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.3",
|
||||
"@typescript-eslint/types": "8.59.4",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1590,6 +1590,18 @@
|
||||
"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": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
@@ -1661,31 +1673,6 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -2575,6 +2562,19 @@
|
||||
"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": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -2847,9 +2847,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "6.37.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.37.0.tgz",
|
||||
"integrity": "sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ==",
|
||||
"version": "6.38.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.38.0.tgz",
|
||||
"integrity": "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"openai": "bin/cli"
|
||||
@@ -2959,14 +2959,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
|
||||
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
"pg-protocol": "^1.13.0",
|
||||
"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"
|
||||
},
|
||||
@@ -2974,7 +2974,7 @@
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.3.0"
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
@@ -2986,16 +2986,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
|
||||
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
|
||||
"version": "2.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
|
||||
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
@@ -3008,18 +3008,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
|
||||
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
||||
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
|
||||
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
@@ -3455,16 +3455,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz",
|
||||
"integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==",
|
||||
"version": "8.59.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz",
|
||||
"integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"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"
|
||||
"@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"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
||||
+4
-4
@@ -22,8 +22,8 @@
|
||||
"emoji-regex": "^10.6.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"ollama": "^0.6.3",
|
||||
"openai": "^6.37.0",
|
||||
"pg": "^8.20.0",
|
||||
"openai": "^6.38.0",
|
||||
"pg": "^8.21.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sharp": "^0.34.5",
|
||||
"systeminformation": "^5.31.6",
|
||||
@@ -35,11 +35,11 @@
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"eslint": "^9.39.4",
|
||||
"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";
|
||||
|
||||
export type AiCapabilityName = keyof AiModelCapabilities;
|
||||
export type AiRuntimePurpose = AiCapabilityName | "chat";
|
||||
export type AiRuntimePurpose = AiCapabilityName | "chat" | "memoryCompress";
|
||||
|
||||
export type AiRuntimeTarget = {
|
||||
provider: AiProvider;
|
||||
@@ -24,6 +24,7 @@ const PURPOSE_SUFFIXES: Record<AiRuntimePurpose, string[]> = {
|
||||
thinking: ["THINKING", "THINK"],
|
||||
extendedThinking: ["EXTENDED_THINKING", "THINKING", "THINK"],
|
||||
tools: ["TOOLS", "CHAT"],
|
||||
memoryCompress: ["MEMORY_COMPRESS"],
|
||||
toolRank: ["TOOL_RANK", "TOOL_RANKER"],
|
||||
audio: ["AUDIO"],
|
||||
documents: ["DOCUMENTS", "RAG", "EMBEDDING"],
|
||||
@@ -155,6 +156,25 @@ export function resolveAiRuntimeTarget(
|
||||
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 {
|
||||
return left.provider === right.provider
|
||||
&& (left.baseUrl ?? "") === (right.baseUrl ?? "")
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {AttachmentKind, AiRuntimeTarget, RuntimeConfigSnapshot} from "./uni
|
||||
import type {OpenAIChatMessage} from "./openai-chat-message";
|
||||
import type {MistralChatMessage} from "./mistral-chat-message";
|
||||
import type {OllamaChatMessage} from "./ollama-chat-message";
|
||||
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
|
||||
|
||||
export type ConversationAttachment = {
|
||||
kind: AttachmentKind;
|
||||
@@ -267,11 +268,13 @@ function buildSystemInstruction(
|
||||
responseLanguage: UserAiResponseLanguage,
|
||||
includePythonToolPrompt: boolean,
|
||||
additions?: string | null,
|
||||
memoryInstruction?: string | null,
|
||||
): string {
|
||||
return [
|
||||
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
|
||||
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
|
||||
additions?.trim() ? additions.trim() : null,
|
||||
memoryInstruction?.trim() ? memoryInstruction.trim() : null,
|
||||
includePythonToolPrompt ? pythonInterpreterToolPrompt : null,
|
||||
].filter(Boolean).join("\n\n");
|
||||
}
|
||||
@@ -310,11 +313,12 @@ export async function buildConversationSnapshot(
|
||||
if (turn.bot) return sum;
|
||||
return sum + turn.attachments.filter(attachment => attachment.kind === "image").length;
|
||||
}, 0);
|
||||
const memoryInstruction = await buildUserMemoryPrompt(msg.from?.id);
|
||||
|
||||
return {
|
||||
turns,
|
||||
imageCount,
|
||||
systemInstruction: buildSystemInstruction(config, responseLanguage, includePythonToolPrompt, runtimeTarget.systemPromptAdditions),
|
||||
systemInstruction: buildSystemInstruction(config, responseLanguage, includePythonToolPrompt, runtimeTarget.systemPromptAdditions, memoryInstruction),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ export async function prepareDocumentRag(
|
||||
const documents = downloads.filter(download => download.kind === "document");
|
||||
if (!documents.length) return undefined;
|
||||
|
||||
if (provider === AiProvider.OPENAI && config.openAiBackend === "compatible") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case AiProvider.OPENAI: {
|
||||
const openAi = createOpenAiClient(config.openAiChatTarget);
|
||||
|
||||
@@ -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,
|
||||
ResponseOutputMessage,
|
||||
} from "openai/resources/responses/responses";
|
||||
import type {ChatCompletionMessageParam} from "openai/resources/chat/completions";
|
||||
|
||||
type OpenAIInputChatMessage = {
|
||||
type: "message";
|
||||
@@ -17,3 +18,5 @@ type OpenAIOutputChatMessage = {
|
||||
} & Pick<ResponseOutputMessage, "id" | "status">;
|
||||
|
||||
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 ?? {});
|
||||
}
|
||||
|
||||
function normalizeToolArgumentsChunk(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === undefined || value === null) return "";
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function extractOpenAiToolCalls(response: unknown): ToolCallData[] {
|
||||
const output = isRecord(response) && Array.isArray(response.output) ? response.output : [];
|
||||
|
||||
@@ -32,6 +38,86 @@ export function extractOpenAiTextDelta(input: unknown): string {
|
||||
return event?.type === "response.output_text.delta" ? event.delta ?? "" : "";
|
||||
}
|
||||
|
||||
export function extractOpenAiChatTextDelta(input: unknown): string {
|
||||
const event = isRecord(input) ? input : undefined;
|
||||
const choice = event && Array.isArray(event.choices) && isRecord(event.choices[0]) ? event.choices[0] : undefined;
|
||||
const delta = isRecord(choice?.delta) ? choice.delta : undefined;
|
||||
const content = delta && typeof delta.content === "string" ? delta.content : "";
|
||||
return content;
|
||||
}
|
||||
|
||||
export function normalizeStreamingTextDelta(existingText: string, deltaText: string): string {
|
||||
if (!deltaText) return "";
|
||||
if (!existingText) return deltaText;
|
||||
|
||||
if (deltaText.startsWith(existingText)) {
|
||||
return deltaText.slice(existingText.length);
|
||||
}
|
||||
|
||||
return deltaText;
|
||||
}
|
||||
|
||||
export function extractOpenAiChatToolCalls(response: unknown): ToolCallData[] {
|
||||
const record = isRecord(response) ? response : undefined;
|
||||
const choice = record && Array.isArray(record.choices) && isRecord(record.choices[0]) ? record.choices[0] : undefined;
|
||||
const message = isRecord(choice?.message) ? choice.message : undefined;
|
||||
const toolCalls = message && Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
|
||||
return toolCalls
|
||||
.filter((item, index) => isRecord(item) && ((typeof item.id === "string") || typeof item.index === "number" || index >= 0))
|
||||
.map((item, index) => {
|
||||
const call = isRecord(item) ? item : {};
|
||||
const fn = isRecord(call.function) ? call.function : undefined;
|
||||
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
|
||||
return {
|
||||
id: normalizeToolCallId(call.id, `openai_chat_${typeof call.index === "number" ? call.index : index}`),
|
||||
name,
|
||||
argumentsText: normalizeToolArguments(fn?.arguments ?? call.arguments),
|
||||
};
|
||||
})
|
||||
.filter(call => call.name.length > 0);
|
||||
}
|
||||
|
||||
export function extractOpenAiChatStreamingToolCalls(input: unknown): ToolCallData[] {
|
||||
const event = isRecord(input) ? input : undefined;
|
||||
const choice = event && Array.isArray(event.choices) && isRecord(event.choices[0]) ? event.choices[0] : undefined;
|
||||
const delta = isRecord(choice?.delta) ? choice.delta : undefined;
|
||||
const toolCalls = Array.isArray(delta?.tool_calls) ? delta.tool_calls : [];
|
||||
|
||||
return toolCalls
|
||||
.map((item, index) => {
|
||||
const call = isRecord(item) ? item : {};
|
||||
const fn = isRecord(call.function) ? call.function : undefined;
|
||||
const name = typeof fn?.name === "string" ? fn.name : typeof call.name === "string" ? call.name : "";
|
||||
return {
|
||||
id: normalizeToolCallId(call.id, `openai_chat_${typeof call.index === "number" ? call.index : index}`),
|
||||
name,
|
||||
argumentsText: normalizeToolArgumentsChunk(fn?.arguments ?? call.arguments),
|
||||
};
|
||||
})
|
||||
.filter(call => call.id.length > 0);
|
||||
}
|
||||
|
||||
export function mergeToolCallChunks(existing: ToolCallData[], chunks: ToolCallData[]): ToolCallData[] {
|
||||
const merged = new Map<string, ToolCallData>(existing.map(call => [call.id, {...call}]));
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const current = merged.get(chunk.id);
|
||||
if (!current) {
|
||||
merged.set(chunk.id, {...chunk});
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.set(chunk.id, {
|
||||
id: current.id,
|
||||
name: current.name || chunk.name,
|
||||
argumentsText: current.argumentsText + (chunk.argumentsText ?? ""),
|
||||
});
|
||||
}
|
||||
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
export function extractOpenAiStreamingToolCalls(input: unknown): ToolCallData[] {
|
||||
const event = input as ResponseStreamEvent | undefined;
|
||||
if (event?.type === "response.output_item.added" && isRecord(event.item) && event.item.type === "function_call") {
|
||||
|
||||
@@ -196,7 +196,8 @@ export async function getRuntimeCapabilities(
|
||||
target?: AiRuntimeTarget
|
||||
): Promise<AiModelCapabilities> {
|
||||
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) {
|
||||
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 {WEB_SEARCH_TOOL_NAME} from "./tools/web-search.js";
|
||||
import {PYTHON_INTERPRETER_TOOL_NAME} from "./tools/python-interpretator.js";
|
||||
import {toolSchemaNames} from "./tool-schema-utils.js";
|
||||
|
||||
export type AiProviderName = "ollama" | "openai" | "mistral";
|
||||
|
||||
@@ -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 = {
|
||||
type: "function";
|
||||
name: string;
|
||||
@@ -79,3 +85,20 @@ export function getProviderTools(provider: AiProvider, forCreator?: boolean): Ai
|
||||
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"]),
|
||||
],
|
||||
),
|
||||
read_user_info: tool(
|
||||
"read_user_info",
|
||||
"Read persistent user memory from user.md.",
|
||||
"Use before editing or when the user asks what you remember about them.",
|
||||
[
|
||||
example("что ты помнишь обо мне?", ["read_user_info"]),
|
||||
example("покажи мою память", ["read_user_info"]),
|
||||
],
|
||||
),
|
||||
read_system_info: tool(
|
||||
"read_system_info",
|
||||
"Read persistent assistant memory from system.md.",
|
||||
"Use before editing or when the user asks what instructions you remember about yourself.",
|
||||
[
|
||||
example("что ты помнишь о себе?", ["read_system_info"]),
|
||||
example("покажи память о тебе", ["read_system_info"]),
|
||||
],
|
||||
),
|
||||
add_user_info: tool(
|
||||
"add_user_info",
|
||||
"Append a durable fact about the user to persistent memory.",
|
||||
"Use when the user asks to remember a new fact, preference, identity detail, or profile information about themselves.",
|
||||
[
|
||||
example("запомни, что меня зовут Иван", ["add_user_info"]),
|
||||
example("запомни, что я люблю чай", ["add_user_info"]),
|
||||
example("remember that I like short answers", ["add_user_info"]),
|
||||
],
|
||||
),
|
||||
add_system_info: tool(
|
||||
"add_system_info",
|
||||
"Append a durable instruction about the assistant to persistent memory.",
|
||||
"Use when the user asks to remember a new assistant identity, style, or behavior instruction.",
|
||||
[
|
||||
example("тебя зовут Евлампий", ["add_system_info"]),
|
||||
example("ты ИИ помощник", ["add_system_info"]),
|
||||
example("remember you are a concise assistant", ["add_system_info"]),
|
||||
],
|
||||
),
|
||||
remove_user_info: tool(
|
||||
"remove_user_info",
|
||||
"Remove a specific user fact from persistent memory.",
|
||||
"Use when the user asks to forget, delete, or remove a specific fact about themselves.",
|
||||
[
|
||||
example("забудь, что я люблю кофе", ["remove_user_info"]),
|
||||
example("удали из памяти, что я живу в Москве", ["remove_user_info"]),
|
||||
example("forget that I work at ACME", ["remove_user_info"]),
|
||||
],
|
||||
),
|
||||
remove_system_info: tool(
|
||||
"remove_system_info",
|
||||
"Remove a specific assistant instruction from persistent memory.",
|
||||
"Use when the user asks to forget or remove a specific instruction about the assistant.",
|
||||
[
|
||||
example("забудь, что тебя зовут Евлампий", ["remove_system_info"]),
|
||||
example("убери правило отвечать коротко", ["remove_system_info"]),
|
||||
example("forget that you are a concise assistant", ["remove_system_info"]),
|
||||
],
|
||||
),
|
||||
replace_user_info: tool(
|
||||
"replace_user_info",
|
||||
"Replace the full user memory with a new compact version.",
|
||||
"Use when the user wants to overwrite all remembered user info, for example when they say to forget everything and keep only the new fact.",
|
||||
[
|
||||
example("забудь всё обо мне и запиши только это: меня зовут Иван", ["replace_user_info"]),
|
||||
example("замени всю память обо мне на: люблю чай и короткие ответы", ["replace_user_info"]),
|
||||
],
|
||||
),
|
||||
replace_system_info: tool(
|
||||
"replace_system_info",
|
||||
"Replace the full assistant memory with a new compact version.",
|
||||
"Use when the user wants to overwrite all remembered assistant info or instructions.",
|
||||
[
|
||||
example("забудь всё о себе и запиши только это: тебя зовут Евлампий", ["replace_system_info"]),
|
||||
example("замени инструкцию о себе на: ты краткий ИИ помощник", ["replace_system_info"]),
|
||||
],
|
||||
),
|
||||
delete_user_info: tool(
|
||||
"delete_user_info",
|
||||
"Delete user.md entirely.",
|
||||
"Use when the user explicitly asks to delete all remembered user info, not just a fragment.",
|
||||
[
|
||||
example("удали всю память обо мне", ["delete_user_info"]),
|
||||
example("forget all user memory", ["delete_user_info"]),
|
||||
],
|
||||
),
|
||||
delete_system_info: tool(
|
||||
"delete_system_info",
|
||||
"Delete system.md entirely.",
|
||||
"Use when the user explicitly asks to delete all remembered assistant info, not just a fragment.",
|
||||
[
|
||||
example("удали всю память о себе", ["delete_system_info"]),
|
||||
example("forget all assistant memory", ["delete_system_info"]),
|
||||
],
|
||||
),
|
||||
create_file: tool(
|
||||
"create_file",
|
||||
"Create a new small file.",
|
||||
@@ -352,6 +446,20 @@ function toolNamesFromTool(tool: BoundaryValue): string[] {
|
||||
return name ? [name] : [];
|
||||
}
|
||||
|
||||
function fallbackToolInfoFromTool(toolValue: BoundaryValue, name: string): ToolRankerToolInfo | undefined {
|
||||
if (!isRecord(toolValue)) return undefined;
|
||||
|
||||
const fn = isRecord(toolValue.function) ? toolValue.function : undefined;
|
||||
const description = asOptionalString(fn?.description ?? toolValue.description)
|
||||
?? `Tool ${name}.`;
|
||||
|
||||
return tool(
|
||||
name,
|
||||
description,
|
||||
"Use when the tool description matches the user's request.",
|
||||
);
|
||||
}
|
||||
|
||||
export function getToolRankerToolInfo(name: string): ToolRankerToolInfo | undefined {
|
||||
return TOOL_RANKER_TOOL_INFOS[name as ToolRankerToolName];
|
||||
}
|
||||
@@ -363,10 +471,25 @@ export function getToolRankerToolInfos(names: readonly string[]): ToolRankerTool
|
||||
}
|
||||
|
||||
export function getToolRankerAvailableToolInfos(availableTools: readonly BoundaryValue[]): ToolRankerToolInfo[] {
|
||||
return getToolRankerToolInfos([
|
||||
"no_tool",
|
||||
...availableTools.flatMap(toolNamesFromTool),
|
||||
]);
|
||||
const infos = new Map<string, ToolRankerToolInfo>();
|
||||
|
||||
infos.set("no_tool", TOOL_RANKER_TOOL_INFOS.no_tool);
|
||||
|
||||
for (const tool of availableTools) {
|
||||
for (const name of toolNamesFromTool(tool)) {
|
||||
if (infos.has(name)) continue;
|
||||
|
||||
const known = getToolRankerToolInfo(name);
|
||||
const fallback = fallbackToolInfoFromTool(tool, name);
|
||||
if (known) {
|
||||
infos.set(name, known);
|
||||
} else if (fallback) {
|
||||
infos.set(name, fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...infos.values()];
|
||||
}
|
||||
|
||||
function renderToolLine(tool: ToolRankerToolInfo, compact: boolean): string {
|
||||
@@ -414,6 +537,16 @@ function buildPriorityLines(tools: readonly ToolRankerToolInfo[]): string[] {
|
||||
pushIfAvailable("read_file", "known local file path -> read_file");
|
||||
pushIfAvailable("list_directory", "project structure or directory listing -> list_directory");
|
||||
pushIfAvailable("search_files", "local file/content search or unknown file path -> search_files");
|
||||
pushIfAvailable("read_user_info", "inspect remembered user info -> read_user_info");
|
||||
pushIfAvailable("read_system_info", "inspect remembered assistant info -> read_system_info");
|
||||
pushIfAvailable("add_user_info", "remember a new user fact -> add_user_info");
|
||||
pushIfAvailable("add_system_info", "remember a new assistant instruction -> add_system_info");
|
||||
pushIfAvailable("remove_user_info", "forget a user fact -> remove_user_info");
|
||||
pushIfAvailable("remove_system_info", "forget an assistant instruction -> remove_system_info");
|
||||
pushIfAvailable("replace_user_info", "overwrite all user memory -> replace_user_info");
|
||||
pushIfAvailable("replace_system_info", "overwrite all assistant memory -> replace_system_info");
|
||||
pushIfAvailable("delete_user_info", "delete all user memory -> delete_user_info");
|
||||
pushIfAvailable("delete_system_info", "delete all assistant memory -> delete_system_info");
|
||||
pushIfAvailable("edit_file_patch", "targeted existing file edit -> edit_file_patch");
|
||||
pushIfAvailable("update_file", "full existing file replacement -> update_file");
|
||||
pushIfAvailable("create_file", "small new file -> create_file");
|
||||
@@ -471,7 +604,7 @@ export function buildToolRankerSystemPrompt(params: {
|
||||
const includeExamples = params.includeExamples ?? false;
|
||||
const maxExamplesPerTool = Math.max(0, params.maxExamplesPerTool ?? 1);
|
||||
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 sections: string[] = [
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import {AiTool} from "../tool-types.js";
|
||||
import path from "node:path";
|
||||
import {readFile, writeFile} from "node:fs/promises";
|
||||
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
import {NOTES_HEADER, notesDir, notesRootFile} from "../../index.js";
|
||||
import {asNonEmptyString} from "./utils.js";
|
||||
import fs from "node:fs";
|
||||
import {toolsLogger} from "./tool-logger";
|
||||
import {AiJsonObject} from "../tool-types";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
import {AiJsonObject} from "../tool-types.js";
|
||||
|
||||
const logger = toolsLogger.child("create-note");
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
import {asNonEmptyString} from "./utils.js";
|
||||
import {AiJsonObject} from "../tool-types";
|
||||
|
||||
export const getCurrentDateTimeTool = {
|
||||
|
||||
@@ -3,8 +3,8 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {z} from "zod";
|
||||
|
||||
import {Environment} from "../../common/environment";
|
||||
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types";
|
||||
import {Environment} from "../../common/environment.js";
|
||||
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types.js";
|
||||
import {
|
||||
MAX_COPY_ENTRIES,
|
||||
MAX_COPY_TOTAL_BYTES,
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
MAX_PATCH_SEARCH_BYTES,
|
||||
MAX_STREAM_WRITE_IDLE_MS,
|
||||
MAX_STREAM_WRITE_SESSIONS,
|
||||
} from "./limits";
|
||||
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils";
|
||||
} from "./limits.js";
|
||||
import {asBoolean, asNonEmptyString, asPositiveInt, asString} from "./utils.js";
|
||||
|
||||
// =============================================================================
|
||||
// Public types and schemas
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import {AiTool} from "../tool-types.js";
|
||||
import axios from "axios";
|
||||
import {toolsLogger} from "./tool-logger";
|
||||
import {AiJsonObject} from "../tool-types";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
import {AiJsonObject} from "../tool-types.js";
|
||||
|
||||
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 {readdir, readFile, stat, unlink, writeFile} from "node:fs/promises";
|
||||
import {notesDir, notesRootFile} from "../../index";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
import {toolsLogger} from "./tool-logger";
|
||||
import {notesDir, notesRootFile} from "../../index.js";
|
||||
import {asNonEmptyString} from "./utils.js";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
import {z} from "zod";
|
||||
import {AiJsonObject} from "../tool-types";
|
||||
import {AiJsonObject} from "../tool-types.js";
|
||||
|
||||
const logger = toolsLogger.child("notes");
|
||||
|
||||
|
||||
+118
-52
@@ -1,17 +1,17 @@
|
||||
import {Environment} from "../../common/environment";
|
||||
import {AiTool} from "../tool-types";
|
||||
import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search";
|
||||
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime";
|
||||
import {shellExecute, shellExecuteTool} from "./shell";
|
||||
import {ToolHandler} from "./types";
|
||||
import {getWeather, getWeatherTool} from "./weather";
|
||||
import {Environment} from "../../common/environment.js";
|
||||
import {AiTool} from "../tool-types.js";
|
||||
import {WEB_SEARCH_TOOL_NAME, webSearch, webSearchTool, webSearchToolPrompt} from "./web-search.js";
|
||||
import {getCurrentDateTime, getCurrentDateTimeTool} from "./datetime.js";
|
||||
import {shellExecute, shellExecuteTool} from "./shell.js";
|
||||
import {ToolHandler} from "./types.js";
|
||||
import {getWeather, getWeatherTool} from "./weather.js";
|
||||
import {
|
||||
GET_FINANCIAL_MARKET_DATA_TOOL_NAME,
|
||||
getFinancialMarketData,
|
||||
getFinancialMarketDataToolPrompt,
|
||||
getMarketRates
|
||||
} from "./market-rates";
|
||||
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator";
|
||||
} from "./market-rates.js";
|
||||
import {pythonInterpreterTool, runPythonInterpreter} from "./python-interpretator.js";
|
||||
import {
|
||||
beginFileWrite,
|
||||
beginFileWriteTool,
|
||||
@@ -44,11 +44,14 @@ import {
|
||||
updateFileTool,
|
||||
writeFileChunk,
|
||||
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[] = [
|
||||
getCurrentDateTimeTool,
|
||||
getFinancialMarketData,
|
||||
...memoryTools,
|
||||
];
|
||||
|
||||
export const fileTools = [
|
||||
@@ -72,44 +75,67 @@ export const fileTools = [
|
||||
deletePathTool,
|
||||
] satisfies AiTool[];
|
||||
|
||||
// export const notesFileTools: AiTool[] = [
|
||||
// createNoteTool,
|
||||
// listNotesTool,
|
||||
// getNoteContentTool,
|
||||
// updateNoteContentTool,
|
||||
// deleteNoteTool,
|
||||
// sendNoteAsFileTool,
|
||||
// searchNotesTool
|
||||
// ]
|
||||
function parseToolNameSet(raw: string | undefined): Set<string> | undefined {
|
||||
if (!raw?.trim()) return undefined;
|
||||
|
||||
const names = raw
|
||||
.split(",")
|
||||
.map(item => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
return names.length ? new Set(names) : undefined;
|
||||
}
|
||||
|
||||
function isLocalToolEnabled(toolName: string): boolean {
|
||||
if (Environment.DISABLE_LOCAL_TOOLS) return false;
|
||||
|
||||
const allowlist = parseToolNameSet(Environment.LOCAL_TOOL_ALLOWLIST);
|
||||
if (allowlist && !allowlist.has(toolName.toLowerCase())) return false;
|
||||
|
||||
const denylist = parseToolNameSet(Environment.LOCAL_TOOL_DENYLIST);
|
||||
if (denylist && denylist.has(toolName.toLowerCase())) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterEnabledTools(tools: AiTool[]): AiTool[] {
|
||||
return tools.filter(tool => isLocalToolEnabled(tool.function.name));
|
||||
}
|
||||
|
||||
export const getTools = (forCreator?: boolean) => {
|
||||
const tools: AiTool[] = [
|
||||
...defaultTools,
|
||||
// ...notesFileTools
|
||||
];
|
||||
const tools: AiTool[] = [];
|
||||
|
||||
if (Environment.DISABLE_LOCAL_TOOLS) {
|
||||
tools.push(...getMcpTools());
|
||||
return tools;
|
||||
}
|
||||
|
||||
tools.push(...filterEnabledTools(defaultTools));
|
||||
|
||||
if (Environment.BRAVE_SEARCH_API_KEY) {
|
||||
tools.push(webSearchTool);
|
||||
tools.push(...filterEnabledTools([webSearchTool]));
|
||||
}
|
||||
|
||||
if (Environment.OPEN_WEATHER_MAP_API_KEY) {
|
||||
tools.push(getWeatherTool);
|
||||
tools.push(...filterEnabledTools([getWeatherTool]));
|
||||
}
|
||||
|
||||
if (Environment.FILE_TOOLS_ROOT_DIR && Environment.ENABLE_FS_TOOLS) {
|
||||
tools.push(...fileTools);
|
||||
tools.push(...filterEnabledTools(fileTools));
|
||||
}
|
||||
|
||||
if (forCreator) {
|
||||
if (Environment.ENABLE_PYTHON_INTERPRETER) {
|
||||
tools.push(pythonInterpreterTool);
|
||||
tools.push(...filterEnabledTools([pythonInterpreterTool]));
|
||||
}
|
||||
|
||||
if (Environment.ENABLE_UNSAFE_EVAL) {
|
||||
tools.push(shellExecuteTool);
|
||||
tools.push(...filterEnabledTools([shellExecuteTool]));
|
||||
}
|
||||
}
|
||||
|
||||
tools.push(...getMcpTools());
|
||||
|
||||
return tools;
|
||||
};
|
||||
|
||||
@@ -135,44 +161,83 @@ export const fileToolHandlers = {
|
||||
};
|
||||
|
||||
export const getToolHandlers = () => {
|
||||
let handlers: Record<string, ToolHandler> = {
|
||||
get_datetime: getCurrentDateTime,
|
||||
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,
|
||||
|
||||
const handlers: Record<string, ToolHandler> = {
|
||||
...getMcpToolHandlers(),
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export function getToolPrompts(toolNames: string[]): string[] {
|
||||
if (Environment.DISABLE_LOCAL_TOOLS) {
|
||||
return getMcpToolPrompts(toolNames);
|
||||
}
|
||||
|
||||
const prompts: string[] = [];
|
||||
const memoryToolNames = new Set(memoryTools.map(tool => tool.function.name));
|
||||
let memoryPromptAdded = false;
|
||||
|
||||
for (const toolName of toolNames) {
|
||||
if (!isLocalToolEnabled(toolName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!prompts.includes(fileToolsToolPrompt) &&
|
||||
fileTools.map(t => t.function.name).includes(toolName)) {
|
||||
prompts.push(fileToolsToolPrompt);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (memoryToolNames.has(toolName)) {
|
||||
if (!memoryPromptAdded) {
|
||||
prompts.push(memoryToolPrompt);
|
||||
memoryPromptAdded = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (toolName) {
|
||||
case GET_FINANCIAL_MARKET_DATA_TOOL_NAME:
|
||||
prompts.push(getFinancialMarketDataToolPrompt);
|
||||
@@ -185,5 +250,6 @@ export function getToolPrompts(toolNames: string[]): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
prompts.push(...getMcpToolPrompts(toolNames));
|
||||
return prompts;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-7
@@ -1,14 +1,19 @@
|
||||
import {getToolHandlers} from "./registry";
|
||||
import {normalizeToolArguments} from "./utils";
|
||||
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator";
|
||||
import {toolsLogger} from "./tool-logger";
|
||||
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
||||
import {getToolHandlers} from "./registry.js";
|
||||
import {normalizeToolArguments} from "./utils.js";
|
||||
import {PYTHON_INTERPRETER_TOOL_NAME, PythonInterpreterInputFile, runPythonInterpreter} from "./python-interpretator.js";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
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");
|
||||
|
||||
export type ToolRuntimeContext = {
|
||||
pythonInputFiles?: PythonInterpreterInputFile[];
|
||||
};
|
||||
provider?: AiProvider;
|
||||
runtimeTarget?: AiRuntimeTarget;
|
||||
} & MemoryRuntimeContext;
|
||||
|
||||
function stringifyToolResult(result: AiJsonValue): string {
|
||||
if (typeof result === "string") return result;
|
||||
@@ -48,7 +53,7 @@ export async function executeToolCall(
|
||||
}
|
||||
|
||||
const arguments1 = normalizeToolArguments(args, userId);
|
||||
const result = await handler(arguments1);
|
||||
const result = await handler(arguments1, context);
|
||||
const s = stringifyToolResult(result);
|
||||
logger.debug("execute.done", {name, chars: s.length, duration: logger.duration(startedAt)});
|
||||
return s;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import {AiTool} from "../tool-types.js";
|
||||
import path from "node:path";
|
||||
import {readdir, readFile} from "node:fs/promises";
|
||||
import {notesDir, notesRootFile} from "../../index";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
import {toolsLogger} from "./tool-logger";
|
||||
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
||||
import {notesDir, notesRootFile} from "../../index.js";
|
||||
import {asNonEmptyString} from "./utils.js";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
import {AiJsonObject, AiJsonValue} from "../tool-types.js";
|
||||
|
||||
const logger = toolsLogger.child("search-notes");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {AiTool} from "../tool-types";
|
||||
import {runCommand} from "../../util/utils";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
import {runCommand} from "../../util/utils.js";
|
||||
import {asNonEmptyString} from "./utils.js";
|
||||
import {AiJsonObject} from "../tool-types";
|
||||
|
||||
export const shellExecuteTool = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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 {toolsLogger} from "./tool-logger";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
import {AiJsonObject, AiJsonValue} from "../tool-types";
|
||||
import type {BoundaryValue} from "../../common/boundary-types";
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import axios from "axios";
|
||||
import {toolsLogger} from "./tool-logger";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
|
||||
const logger = toolsLogger.child("weather");
|
||||
import {Environment} from "../../common/environment";
|
||||
import {logError} from "../../util/utils";
|
||||
import {AiJsonObject, AiTool} from "../tool-types";
|
||||
import {asNonEmptyString} from "./utils";
|
||||
import {Environment} from "../../common/environment.js";
|
||||
import {logError} from "../../util/utils.js";
|
||||
import {AiJsonObject, AiTool} from "../tool-types.js";
|
||||
import {asNonEmptyString} from "./utils.js";
|
||||
|
||||
export const getWeatherTool = {
|
||||
type: "function",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import axios from "axios";
|
||||
import {toolsLogger} from "./tool-logger";
|
||||
import {toolsLogger} from "./tool-logger.js";
|
||||
|
||||
const logger = toolsLogger.child("brave-search");
|
||||
import {Environment} from "../../common/environment";
|
||||
import {logError} from "../../util/utils";
|
||||
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types";
|
||||
import {asBoolean, asNonEmptyString} from "./utils";
|
||||
import {Environment} from "../../common/environment.js";
|
||||
import {logError} from "../../util/utils.js";
|
||||
import {AiJsonObject, AiJsonValue, AiTool} from "../tool-types.js";
|
||||
import {asBoolean, asNonEmptyString} from "./utils.js";
|
||||
|
||||
type BraveSearchProfile = {
|
||||
name?: string;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "./unified-ai-runner.shared";
|
||||
import {runToolRankStage} from "./tool-rank-stage";
|
||||
import {runOpenAi} from "./unified-ai-runner.openai";
|
||||
import {runOpenAiCompatible} from "./unified-ai-runner.openai-compatible";
|
||||
import {runOllama} from "./unified-ai-runner.ollama";
|
||||
import {runMistral} from "./unified-ai-runner.mistral";
|
||||
import {summarizeModelOutput} from "./response-model-output";
|
||||
@@ -80,6 +81,21 @@ async function runProviderModelCall(params: {
|
||||
|
||||
switch (options.provider) {
|
||||
case AiProvider.OPENAI:
|
||||
if (config.openAiBackend === "compatible") {
|
||||
await runOpenAiCompatible(
|
||||
options.msg,
|
||||
prepared.chatMessages as OpenAIChatMessage[],
|
||||
streamMessage,
|
||||
signal,
|
||||
options.stream ?? true,
|
||||
options.msg,
|
||||
config,
|
||||
prepared.toolContext,
|
||||
downloads,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await runOpenAi(
|
||||
options.msg,
|
||||
prepared.chatMessages as OpenAIChatMessage[],
|
||||
|
||||
@@ -7,6 +7,8 @@ import {aiLog, aiLogDuration, aiLogProviderTarget, aiLogToolCall} from "../loggi
|
||||
import {AiProvider} from "../model/ai-provider";
|
||||
import {getProviderAdapter} from "./provider-adapters";
|
||||
import {runToolRankStage} from "./tool-rank-stage";
|
||||
import {ensureToolsSelected} from "./tool-mappers.js";
|
||||
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
|
||||
|
||||
import {
|
||||
MAX_TOOL_ROUNDS,
|
||||
@@ -66,7 +68,7 @@ export async function runMistral(
|
||||
streamMessage,
|
||||
signal,
|
||||
});
|
||||
const filteredTools = rankResult.filteredTools;
|
||||
const filteredTools = ensureToolsSelected(availableTools, rankResult.filteredTools, MEMORY_TOOL_NAMES);
|
||||
const requestTools = filteredTools.length ? filteredTools : undefined;
|
||||
|
||||
streamMessage.setStatus(roundStatus(round, firstRoundStatus) ?? "");
|
||||
@@ -113,7 +115,11 @@ export async function runMistral(
|
||||
userId: msg.from?.id,
|
||||
toolCalls: calls,
|
||||
streamMessage,
|
||||
toolContext,
|
||||
toolContext: {
|
||||
...toolContext,
|
||||
provider: AiProvider.MISTRAL,
|
||||
runtimeTarget: config.mistralChatTarget,
|
||||
},
|
||||
toolMemory,
|
||||
adapter,
|
||||
appendTargets: [messages, requestMessages],
|
||||
@@ -183,7 +189,11 @@ export async function runMistral(
|
||||
userId: msg.from?.id,
|
||||
toolCalls: calls,
|
||||
streamMessage,
|
||||
toolContext,
|
||||
toolContext: {
|
||||
...toolContext,
|
||||
provider: AiProvider.MISTRAL,
|
||||
runtimeTarget: config.mistralChatTarget,
|
||||
},
|
||||
toolMemory,
|
||||
adapter,
|
||||
appendTargets: [messages, requestMessages],
|
||||
|
||||
@@ -15,6 +15,8 @@ import {createOllamaClient} from "./ai-runtime-target";
|
||||
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
||||
import {getProviderAdapter} from "./provider-adapters";
|
||||
import {runToolRankStage} from "./tool-rank-stage";
|
||||
import {ensureToolsSelected} from "./tool-mappers.js";
|
||||
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
|
||||
|
||||
import {
|
||||
allToolSchemaNames,
|
||||
@@ -203,7 +205,7 @@ export async function runOllama(
|
||||
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 ?? "");
|
||||
if (filteredTools.length > 0) {
|
||||
request.tools = [...filteredTools];
|
||||
@@ -297,7 +299,11 @@ export async function runOllama(
|
||||
userId: msg.from?.id,
|
||||
toolCalls: calls,
|
||||
streamMessage,
|
||||
toolContext,
|
||||
toolContext: {
|
||||
...toolContext,
|
||||
provider: AiProvider.OLLAMA,
|
||||
runtimeTarget: target,
|
||||
},
|
||||
toolMemory,
|
||||
adapter,
|
||||
appendTargets: [messages],
|
||||
@@ -429,7 +435,11 @@ export async function runOllama(
|
||||
userId: msg.from?.id,
|
||||
toolCalls: calls,
|
||||
streamMessage,
|
||||
toolContext,
|
||||
toolContext: {
|
||||
...toolContext,
|
||||
provider: AiProvider.OLLAMA,
|
||||
runtimeTarget: target,
|
||||
},
|
||||
toolMemory,
|
||||
adapter,
|
||||
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";
|
||||
import {createOpenAiClient} from "./ai-runtime-target";
|
||||
import {aiLog, aiLogDuration, aiLogMessageIdentity, aiLogProviderTarget, aiLogToolCall} from "../logging/ai-logger";
|
||||
import {buildUserMemoryPrompt} from "./tools/user-memory.js";
|
||||
|
||||
import {
|
||||
AsyncIterableStream,
|
||||
@@ -29,23 +30,21 @@ import {
|
||||
showOpenAiGeneratedImage,
|
||||
ToolCallData,
|
||||
ToolExecutionMemory,
|
||||
errorMessage,
|
||||
allToolSchemaNames
|
||||
} from "./unified-ai-runner.shared";
|
||||
import {executeToolBatchWithAdapter} from "./tool-batch-runner";
|
||||
import {decideToolLoopContinuation} from "./tool-loop-control";
|
||||
import {runToolLoopRounds} from "./tool-loop-runner";
|
||||
import {runSingleModelRequest} from "./model-call-stage";
|
||||
import {bot} from "../index";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {ensureToolsSelected} from "./tool-mappers.js";
|
||||
import {MEMORY_TOOL_NAMES} from "./tools/user-memory.js";
|
||||
import {logError} from "../util/utils";
|
||||
import {SendFileAttachmentResult, SendFileAttachmentResultSchema} from "./tools/files";
|
||||
import {DEFAULT_AI_RESPONSE_LANGUAGE} from "../common/user-ai-settings";
|
||||
import {AiDownloadedFile} from "./telegram-attachments";
|
||||
import {AiProvider} from "../model/ai-provider";
|
||||
import {getProviderAdapter} from "./provider-adapters";
|
||||
import {runToolRankStage} from "./tool-rank-stage";
|
||||
import {tryToUploadFiles} from "./openai-upload-files.js";
|
||||
|
||||
export async function runOpenAi(
|
||||
msg: Message,
|
||||
@@ -75,6 +74,7 @@ export async function runOpenAi(
|
||||
DEFAULT_AI_RESPONSE_LANGUAGE,
|
||||
false,
|
||||
config.openAiChatTarget.systemPromptAdditions,
|
||||
await buildUserMemoryPrompt(msg.from?.id),
|
||||
);
|
||||
|
||||
aiLog("info", "openai.run.start", {
|
||||
@@ -115,9 +115,13 @@ export async function runOpenAi(
|
||||
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) {
|
||||
const request: ResponseCreateParamsNonStreaming = {
|
||||
@@ -187,7 +191,11 @@ export async function runOpenAi(
|
||||
userId: msg.from?.id,
|
||||
toolCalls,
|
||||
streamMessage,
|
||||
toolContext,
|
||||
toolContext: {
|
||||
...toolContext,
|
||||
provider: AiProvider.OPENAI,
|
||||
runtimeTarget: config.openAiChatTarget,
|
||||
},
|
||||
toolMemory,
|
||||
adapter,
|
||||
appendTargets: [toolOutputs],
|
||||
@@ -397,7 +405,11 @@ export async function runOpenAi(
|
||||
userId: msg.from?.id,
|
||||
toolCalls,
|
||||
streamMessage,
|
||||
toolContext,
|
||||
toolContext: {
|
||||
...toolContext,
|
||||
provider: AiProvider.OPENAI,
|
||||
runtimeTarget: config.openAiChatTarget,
|
||||
},
|
||||
toolMemory,
|
||||
adapter,
|
||||
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 {
|
||||
// if (typeof content === "string") return content;
|
||||
// if (!Array.isArray(content)) return "";
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import type {BoundaryValue} from "../common/boundary-types";
|
||||
import {AiProvider} from "../model/ai-provider.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 {MessageStore} from "../common/message-store.js";
|
||||
import type {OpenAiResponseTool} from "./tool-mappers.js";
|
||||
@@ -274,6 +274,7 @@ export type RuntimeConfigSnapshot = {
|
||||
openAiChatTarget: AiRuntimeTarget;
|
||||
openAiImageTarget: AiRuntimeTarget;
|
||||
openAiToolRankerTarget?: AiRuntimeTarget;
|
||||
openAiBackend: OpenAiBackend;
|
||||
};
|
||||
|
||||
export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
|
||||
@@ -307,9 +308,14 @@ export function snapshotRuntimeConfig(): RuntimeConfigSnapshot {
|
||||
openAiChatTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "chat"),
|
||||
openAiImageTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "outputImages"),
|
||||
openAiToolRankerTarget: resolveAiRuntimeTarget(AiProvider.OPENAI, "toolRank"),
|
||||
openAiBackend: Environment.OPENAI_BACKEND,
|
||||
};
|
||||
}
|
||||
|
||||
export function isOpenAiCompatibleBackend(config: RuntimeConfigSnapshot): boolean {
|
||||
return config.openAiBackend === "compatible";
|
||||
}
|
||||
|
||||
export function getMessageImageParts(part: MessagePart): MessageImagePart[] {
|
||||
if (part.imageParts?.length) return part.imageParts;
|
||||
return (part.images ?? []).map(data => ({data, mimeType: "image/jpeg"}));
|
||||
@@ -382,11 +388,13 @@ export function buildSystemInstruction(
|
||||
responseLanguage: UserAiResponseLanguage,
|
||||
includePythonToolPrompt: boolean,
|
||||
additions?: string | null,
|
||||
memoryInstruction?: string | null,
|
||||
): string {
|
||||
return [
|
||||
config.useSystemPrompt ? getResponseLanguageInstruction(responseLanguage) : null,
|
||||
config.systemPrompt && config.useSystemPrompt ? config.systemPrompt : null,
|
||||
additions?.trim() ? additions.trim() : null,
|
||||
memoryInstruction?.trim() ? memoryInstruction.trim() : null,
|
||||
includePythonToolPrompt ? pythonInterpreterToolPrompt : null,
|
||||
].filter(Boolean).join("\n\n");
|
||||
}
|
||||
@@ -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 pathValue = typeof args.path === "string" ? args.path : undefined;
|
||||
const sourcePath = typeof args.sourcePath === "string" ? args.sourcePath : 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) {
|
||||
case "read_user_info":
|
||||
case "read_system_info":
|
||||
case "get_datetime":
|
||||
case "web_search":
|
||||
case "get_weather":
|
||||
case "read_file":
|
||||
case "list_directory":
|
||||
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_directory":
|
||||
case "update_file":
|
||||
@@ -1162,7 +1182,7 @@ export async function executeScheduledTool(
|
||||
message: TelegramStreamMessage,
|
||||
context: ToolRuntimeContext,
|
||||
): Promise<string> {
|
||||
const keys = toolResourceKeys(toolCall);
|
||||
const keys = toolResourceKeys(toolCall, userId);
|
||||
if (!keys.length) return 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 {BoundaryValue} from "../common/boundary-types.js";
|
||||
import {ToolRankerFallbackPolicy} from "../common/policies.js";
|
||||
@@ -107,7 +107,7 @@ export class ToolRanker {
|
||||
target: aiLogProviderTarget(target),
|
||||
fallbackTarget: aiLogProviderTarget(mainModelTarget),
|
||||
duration: aiLogDuration(startedAt),
|
||||
error: failureMessage,
|
||||
errorSummary: failureMessage,
|
||||
});
|
||||
|
||||
const fallbackRanker = buildToolRankerPrompt(
|
||||
@@ -142,7 +142,7 @@ export class ToolRanker {
|
||||
target: aiLogProviderTarget(target),
|
||||
fallbackTarget: aiLogProviderTarget(mainModelTarget),
|
||||
duration: aiLogDuration(startedAt),
|
||||
error: fallbackErrorMessage,
|
||||
errorSummary: fallbackErrorMessage,
|
||||
});
|
||||
|
||||
failureMessage = fallbackErrorMessage;
|
||||
@@ -155,7 +155,7 @@ export class ToolRanker {
|
||||
target: aiLogProviderTarget(target),
|
||||
fallbackPolicy,
|
||||
duration: aiLogDuration(startedAt),
|
||||
error: failureMessage,
|
||||
errorSummary: failureMessage,
|
||||
});
|
||||
|
||||
return resolveToolRankerFallbackSelection({
|
||||
@@ -227,12 +227,19 @@ export class ToolRanker {
|
||||
{role: "user", content: userQuery},
|
||||
] satisfies ChatCompletionMessageParam[];
|
||||
|
||||
// gpt-5 family ranker targets reject temperature=0; use the model default instead.
|
||||
const response = await openAi.chat.completions.create({
|
||||
// OpenAI-compatible servers often reject `response_format`, so keep JSON mode
|
||||
// only for official OpenAI endpoints.
|
||||
const request: ChatCompletionCreateParamsNonStreaming = {
|
||||
model: target.model,
|
||||
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() ?? "";
|
||||
}
|
||||
|
||||
@@ -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 {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 {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return ["true", "t", "y", "1"].includes(normalized);
|
||||
@@ -214,6 +221,10 @@ const RuntimeEnvSchema = z.object({
|
||||
SEND_TIME_TOOK: 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_ADDRESS: optionalStringSchema,
|
||||
@@ -241,6 +252,10 @@ const RuntimeEnvSchema = z.object({
|
||||
|
||||
OPENAI_BASE_URL: optionalStringSchema,
|
||||
OPENAI_API_KEY: optionalStringSchema,
|
||||
OPENAI_BACKEND: enumWithDefaultSchema(
|
||||
OpenAiBackendModes,
|
||||
OpenAiBackendModes.OFFICIAL,
|
||||
),
|
||||
OPENAI_MODEL: stringWithDefaultSchema("gpt-4.1-nano"),
|
||||
OPENAI_IMAGE_MODEL: stringWithDefaultSchema("gpt-image-1-mini"),
|
||||
OPENAI_TRANSCRIPTION_MODEL: stringWithDefaultSchema("gpt-4o-mini-transcribe"),
|
||||
@@ -308,6 +323,10 @@ export class Environment {
|
||||
static SEND_TIME_TOOK: 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_ADDRESS?: string;
|
||||
@@ -335,6 +354,7 @@ export class Environment {
|
||||
|
||||
static OPENAI_BASE_URL?: string;
|
||||
static OPENAI_API_KEY?: string;
|
||||
static OPENAI_BACKEND: OpenAiBackend = OpenAiBackendModes.OFFICIAL;
|
||||
static OPENAI_MODEL: string = "";
|
||||
static OPENAI_IMAGE_MODEL: string = "";
|
||||
static OPENAI_TRANSCRIPTION_MODEL: string = "";
|
||||
@@ -1842,6 +1862,10 @@ export class Environment {
|
||||
Environment.SEND_TIME_TOOK = env.SEND_TIME_TOOK ?? 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_ADDRESS = env.OLLAMA_ADDRESS;
|
||||
@@ -1869,6 +1893,7 @@ export class Environment {
|
||||
|
||||
Environment.OPENAI_BASE_URL = env.OPENAI_BASE_URL;
|
||||
Environment.OPENAI_API_KEY = env.OPENAI_API_KEY;
|
||||
Environment.OPENAI_BACKEND = env.OPENAI_BACKEND;
|
||||
Environment.OPENAI_MODEL = env.OPENAI_MODEL;
|
||||
Environment.OPENAI_IMAGE_MODEL = env.OPENAI_IMAGE_MODEL;
|
||||
Environment.OPENAI_TRANSCRIPTION_MODEL = env.OPENAI_TRANSCRIPTION_MODEL;
|
||||
@@ -2069,6 +2094,10 @@ export class Environment {
|
||||
this.OPENAI_API_KEY = newAIApiKey;
|
||||
}
|
||||
|
||||
static setOpenAIBackend(newBackend: OpenAiBackend): void {
|
||||
this.OPENAI_BACKEND = newBackend;
|
||||
}
|
||||
|
||||
static setOpenAIModel(newModel: string): void {
|
||||
this.OPENAI_MODEL = newModel;
|
||||
}
|
||||
|
||||
@@ -2055,7 +2055,24 @@ export class DatabaseManager {
|
||||
}
|
||||
|
||||
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 artifacts = messages.flatMap(message => DatabaseManager.artifactRowsFromMessageRow(message));
|
||||
const requestAudits = messages.flatMap(message => DatabaseManager.requestAuditRowsFromMessageRow(message));
|
||||
|
||||
+12
-3
@@ -79,6 +79,7 @@ import {AIAudit} from "./commands/ai-audit.js";
|
||||
import {AIMetrics} from "./commands/ai-metrics.js";
|
||||
import {AIRequests} from "./commands/ai-requests.js";
|
||||
import {cleanupStaleRagProviderState} from "./ai/rag-retention.js";
|
||||
import {initializeMcpTools, shutdownMcpTools} from "./ai/mcp/mcp-registry.js";
|
||||
|
||||
process.setUncaughtExceptionCaptureCallback(logError);
|
||||
|
||||
@@ -193,6 +194,7 @@ export const filesDir = path.join(Environment.DATA_PATH, "files");
|
||||
export const NOTES_HEADER = "## Notes\n";
|
||||
export const notesDir = path.join(Environment.DATA_PATH, "notes");
|
||||
export const notesRootFile = path.join(notesDir, "index.md");
|
||||
export const memoryDir = path.join(Environment.DATA_PATH, "memory");
|
||||
|
||||
const logger = appLogger.child("main");
|
||||
|
||||
@@ -236,11 +238,17 @@ export async function shutdown(signal: NodeJS.Signals | "manual") {
|
||||
logError(error instanceof Error ? error : String(error));
|
||||
} finally {
|
||||
try {
|
||||
await DatabaseManager.close();
|
||||
await shutdownMcpTools();
|
||||
} catch (error) {
|
||||
logError(error instanceof Error ? error : String(error));
|
||||
} finally {
|
||||
try {
|
||||
await DatabaseManager.close();
|
||||
} catch (error) {
|
||||
logError(error instanceof Error ? error : String(error));
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +263,7 @@ async function main() {
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const created: string[] = [];
|
||||
for (const dir of dirsToCheck) {
|
||||
@@ -280,6 +288,7 @@ async function main() {
|
||||
|
||||
await measureStartupStep("cleanup_internal_artifacts", () => cleanupInternalArtifactCache(), () => ({retentionDays: 14}));
|
||||
await measureStartupStep("cleanup_stale_rag_provider_state", () => cleanupStaleRagProviderState(), () => ({retentionDays: 14}));
|
||||
await measureStartupStep("mcp.initialize", () => initializeMcpTools());
|
||||
await measureStartupStep("observability.snapshot", async () => {
|
||||
const [aiRequests, attachments, artifacts, requestAudits] = await Promise.all([
|
||||
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,
|
||||
extractOpenAiStreamingToolCalls,
|
||||
extractOpenAiTextDelta,
|
||||
extractOpenAiChatToolCalls,
|
||||
extractOpenAiChatStreamingToolCalls,
|
||||
extractOpenAiChatTextDelta,
|
||||
mergeToolCallChunks,
|
||||
normalizeStreamingTextDelta,
|
||||
extractMistralToolCalls,
|
||||
extractMistralTextDelta,
|
||||
extractOllamaToolCalls,
|
||||
@@ -42,6 +47,62 @@ test("openai contract extracts text delta and function calls", () => {
|
||||
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", () => {
|
||||
assert.equal(extractMistralTextDelta({
|
||||
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"]})));
|
||||
});
|
||||
|
||||
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", () => {
|
||||
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