From b615518f22e6760c6f82eb18b0a278a14f8b2b96 Mon Sep 17 00:00:00 2001 From: yunogasai Date: Thu, 24 Sep 2026 20:55:06 +0200 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D1=82=20=D0=B4=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=20Mastra=20Memory=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=20=D1=81=D0=B2=D0=BE=D0=B5=D0=B9=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Контекст диалога хранит Mastra Memory (LibSQL, memory.db рядом с DB_PATH), тред на чат - Бюджет истории — messageHistory.maxTokens (CONTEXT_TOKEN_LIMIT, по умолчанию 12000) - ToolCallFilter: результаты инструментов не попадают в прошлые реплики, внутри ответа — только свежий шаг - Системный промпт стабилен для всего чата (кешируется провайдером); автор и упоминания — в - Фото передаются через context и не сохраняются в памяти - maxSteps и maxOutputTokens (ANSWER_TOKEN_LIMIT) — в defaultOptions агента - /context и /clear работают через Memory recall/deleteThread - Удалены свои dialog_messages, dialog-repository и dialog-service Co-Authored-By: Claude Opus 5.5 --- .env.example | 6 +- CLAUDE.md | 5 +- README.md | 5 +- package.json | 2 + pnpm-lock.yaml | 307 ++++++++++++++++++ prompts/system.md | 3 + src/bot/create-bot.ts | 5 +- src/bot/handlers/context-handlers.ts | 17 +- src/bot/handlers/question-handlers.ts | 2 +- src/config.ts | 11 +- src/index.ts | 14 +- src/libs/db.ts | 18 +- src/repositories/dialog-repository.ts | 41 --- src/services/assistant/assistant-service.ts | 86 +++-- src/services/assistant/memory.ts | 30 ++ src/services/assistant/prompts.ts | 50 +-- .../assistant/tools/request-context.ts | 8 +- src/services/dialog-service.ts | 27 -- src/types.ts | 6 - 19 files changed, 464 insertions(+), 179 deletions(-) delete mode 100644 src/repositories/dialog-repository.ts create mode 100644 src/services/assistant/memory.ts delete mode 100644 src/services/dialog-service.ts diff --git a/.env.example b/.env.example index 17307e3..a612dcb 100644 --- a/.env.example +++ b/.env.example @@ -20,5 +20,7 @@ OPENROUTER_API_KEY= DB_PATH=data/bot.db HISTORY_LIMIT=5000 -# Сколько последних пар «вопрос — ответ» бот помнит в контексте диалога -DIALOG_LIMIT=10 +# Бюджет токенов на промпт: сверх него Mastra Memory срезает самые старые реплики диалога +CONTEXT_TOKEN_LIMIT=12000 +# Максимальная длина ответа в токенах +ANSWER_TOKEN_LIMIT=1500 diff --git a/CLAUDE.md b/CLAUDE.md index 5af3b46..868c050 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,13 +25,14 @@ src/ libs/ — инфраструктура без бизнес-логики (SQLite и миграции, промпты, форматирование Telegram, парсер экспорта) repositories/ — только SQL и маппинг строк в доменные типы, по репозиторию на таблицу services/ — бизнес-логика; зависят от репозиториев, не знают про grammY - assistant/ — AI на Mastra: агент, промпты, tools + assistant/ — AI на Mastra: агент, память (Memory), промпты, tools bot/ — grammY: сборка бота, общие хелперы ответа, скачивание фото handlers/ — тонкие Composer'ы: разбор команды → вызов сервиса → ответ prompts/ — тексты промптов: system.md (правила бота) и personas/.md ``` -- Память разделена: архив чата (`messages`, читается инструментами) и контекст диалога (`dialog_messages`, подкладывается в каждый запрос). `/clear` чистит только контекст. +- Память разделена: архив чата (`messages` в SQLite бота, читается инструментами) и контекст диалога (Mastra Memory, `data/memory.db`, тред на чат). `/clear` чистит только контекст. +- Где Mastra закрывает потребность (память, бюджет токенов, фильтры, процессоры) — используем Mastra, а не своё. - Промпты храним только в `prompts/*.md`, не в коде. - Личности не редактируются из чата: источник правды — код и `prompts/`, в БД их кладёт `seed`. - Изменения схемы БД — через `migrate` в `libs/db.ts` (у пользователей уже есть базы). diff --git a/README.md b/README.md index b81c17f..1408bd2 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Telegram-бот для групповых чатов с AI-личностями. - **Участники**: бот запоминает имя, фамилию и ник каждого, кто пишет в чат (и тех, кого упомянули по имени-ссылке). Можно спрашивать «что писал @ivan?», «что думает Иван про выручку?», «кто тут самый активный?» — агент найдёт человека по нику или имени (инструменты `list_chat_members`, `read_member_messages`) и обращается к людям по имени. - **Фото**: пришли фото с подписью, где упомянут бот (в личке — просто фото), или ответь на фото через `/ask` / упоминание — модель посмотрит картинку. Нужна модель со зрением (Gemini через OpenRouter по умолчанию); DeepSeek фото не видит и честно об этом скажет. - **Память — две части**: - - *Контекст диалога* — последние `DIALOG_LIMIT` вопросов к боту и его ответов в этом чате. Подкладывается в каждый запрос, поэтому можно уточнять «а подробнее?». `/clear` сбрасывает только его. + - *Контекст диалога* — вопросы к боту и его ответы в этом чате, хранит [Mastra Memory](https://mastra.ai/docs/memory/message-history) (`memory.db` рядом с `DB_PATH`). Подкладывается в каждый запрос, поэтому можно уточнять «а подробнее?». Самые старые реплики Mastra срезает, когда промпт не влезает в `CONTEXT_TOKEN_LIMIT`; результаты инструментов из прошлых ответов (`ToolCallFilter`) и фото в историю не попадают. `/clear` сбрасывает только контекст. - *Архив чата* — вся переписка (живая и импортированная). Модель читает его инструментами по необходимости; `/clear` его не трогает. - `/context` — что сейчас в контексте и в архиве, ID чата, видит ли бот всю переписку; `/import` — загрузить старую переписку из экспорта (см. ниже). - **Личности**: задаются в коде, из чата не редактируются. Сейчас одна — `boltun` (Dirty D). @@ -67,7 +67,8 @@ pnpm dev \* нужен ключ того провайдера, чья модель выбрана; бот проверяет это при старте. | `DB_PATH` | нет | путь к SQLite, по умолчанию `data/bot.db` (в Docker — `/app/data/bot.db`) | | `HISTORY_LIMIT` | нет | сколько сообщений на чат хранить в архиве, по умолчанию 5000 | -| `DIALOG_LIMIT` | нет | сколько последних пар «вопрос — ответ» бот помнит в контексте, по умолчанию 10 | +| `CONTEXT_TOKEN_LIMIT` | нет | бюджет токенов на весь промпт (правила, история диалога, вопрос); сверх него срезаются самые старые реплики. По умолчанию 12000 | +| `ANSWER_TOKEN_LIMIT` | нет | максимальная длина ответа в токенах, по умолчанию 1500 | ## Деплой (Dokploy) diff --git a/package.json b/package.json index 89d47ad..9f59ba2 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ }, "dependencies": { "@mastra/core": "^1.70.0", + "@mastra/libsql": "^1.23.2", + "@mastra/memory": "^1.32.0", "better-sqlite3": "^13.0.3", "grammy": "^1.46.0", "zod": "^4.6.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c151de..29393b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@mastra/core': specifier: ^1.70.0 version: 1.70.0(zod@4.6.5) + '@mastra/libsql': + specifier: ^1.23.2 + version: 1.23.2(@mastra/core@1.70.0(zod@4.6.5)) + '@mastra/memory': + specifier: ^1.32.0 + version: 1.32.0(@mastra/core@1.70.0(zod@4.6.5)) better-sqlite3: specifier: ^13.0.3 version: 13.0.3 @@ -263,6 +269,63 @@ packages: resolution: {integrity: sha512-VwGZqqjAWPICTmxUZnbpEfO60LhPWzquik+bmyXGY7pYRn6diEvCI5i6Ca+J6o2y4vS73HrpuMTo2dOvUevH8w==} engines: {node: '>=12'} + '@libsql/client@0.18.0': + resolution: {integrity: sha512-zMCCo58vv7w27j7+cnt/GW+X6/Rih6cHZ86PKi0AxCMOqMfrVgG9OZ5lW2bDMJhSeOjsv2szH2nJF3A9l36UmA==} + + '@libsql/core@0.18.0': + resolution: {integrity: sha512-N8RR2CIpcgtbKZnXs2KVpw2lJQfinUj3I56e7qRDRpK18Hty0Rr8nr00VmFb4R/DsqOanPB/TaP3+QAQLZeXMg==} + + '@libsql/darwin-arm64@0.5.29': + resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==} + cpu: [arm64] + os: [darwin] + + '@libsql/darwin-x64@0.5.29': + resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==} + cpu: [x64] + os: [darwin] + + '@libsql/hrana-client@0.10.0': + resolution: {integrity: sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==} + + '@libsql/isomorphic-ws@0.1.5': + resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} + + '@libsql/linux-arm-gnueabihf@0.5.29': + resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm-musleabihf@0.5.29': + resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm64-gnu@0.5.29': + resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-arm64-musl@0.5.29': + resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-x64-gnu@0.5.29': + resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==} + cpu: [x64] + os: [linux] + + '@libsql/linux-x64-musl@0.5.29': + resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==} + cpu: [x64] + os: [linux] + + '@libsql/win32-x64-msvc@0.5.29': + resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==} + cpu: [x64] + os: [win32] + '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -277,12 +340,27 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@mastra/libsql@1.23.2': + resolution: {integrity: sha512-4ymFlEf0RWYULj4bUk93vHx4JkWm66nuAP0BuC2lDi0OgjvWczNh29S7suer8/QkEAFP0tO0l291Xqx/3Q31mw==} + engines: {node: '>=22.13.0'} + peerDependencies: + '@mastra/core': '>=1.68.0-0 <2.0.0-0' + + '@mastra/memory@1.32.0': + resolution: {integrity: sha512-dYCdFs6wftaRJewJK7gb6AGbBmEY8fmQT7DKodRF2aolLf5bhxnniJkvuN3CcPv1b163MzpQlE4N1E0zr8deAA==} + engines: {node: '>=22.13.0'} + peerDependencies: + '@mastra/core': '>=1.4.1-0 <2.0.0-0' + '@mastra/schema-compat@1.3.11': resolution: {integrity: sha512-NvR+wYk4/d0uqzk702Aehd8Vbop+844c1Dp7TMkZVis71LzPORWJbFPQswrom9o+v5ollUfif+28lZzutGMq+A==} engines: {node: '>=22.13.0'} peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@neon-rs/load@0.0.4': + resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@posthog/core@1.55.2': resolution: {integrity: sha512-2mYxGbDmTLUs096VbcZqSWo0lrI48/BrX65r54NynxxFq409BOx9w73HxOAtOKZ1vblkdV6/GxQTe7KDcIQidg==} @@ -380,6 +458,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -513,6 +594,9 @@ packages: argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -549,6 +633,22 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -565,9 +665,17 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -642,6 +750,10 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + ignore@7.0.10: resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==} engines: {node: '>= 4'} @@ -675,6 +787,9 @@ packages: jpeg-js@0.4.4: resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-base64@3.9.4: + resolution: {integrity: sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==} + js-yaml@3.15.2: resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} hasBin: true @@ -690,6 +805,14 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + libsql@0.5.29: + resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} + cpu: [x64, arm64, wasm32, arm] + os: [darwin, linux, win32] + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -817,9 +940,17 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + needle@2.9.1: + resolution: {integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==} + engines: {node: '>= 4.4.x'} + hasBin: true + node-addon-api@8.9.2: resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} engines: {node: ^18 || ^20 || >= 21} @@ -877,6 +1008,12 @@ packages: resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} + probe-image-size@7.4.0: + resolution: {integrity: sha512-cdEprVtZxV+awMde9X+4jILBFYh4CARxVrQaMl4wY4YcPWbul9jntXrIW95NInBDyJwcVUP3U0T6yukN8rMBaQ==} + + promise-limit@2.7.0: + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -896,6 +1033,13 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + section-matter@1.0.0: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} @@ -915,6 +1059,9 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stream-parser@0.3.1: + resolution: {integrity: sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==} + strip-bom-string@1.0.0: resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} engines: {node: '>=0.10.0'} @@ -929,6 +1076,9 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.15: resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} engines: {node: '>=18.0.0'} @@ -1139,6 +1289,64 @@ snapshots: '@isaacs/ttlcache@2.1.5': {} + '@libsql/client@0.18.0': + dependencies: + '@libsql/core': 0.18.0 + '@libsql/hrana-client': 0.10.0 + js-base64: 3.9.4 + libsql: 0.5.29 + promise-limit: 2.7.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/core@0.18.0': + dependencies: + js-base64: 3.9.4 + + '@libsql/darwin-arm64@0.5.29': + optional: true + + '@libsql/darwin-x64@0.5.29': + optional: true + + '@libsql/hrana-client@0.10.0': + dependencies: + '@libsql/isomorphic-ws': 0.1.5 + js-base64: 3.9.4 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/isomorphic-ws@0.1.5': + dependencies: + '@types/ws': 8.18.1 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/linux-arm-gnueabihf@0.5.29': + optional: true + + '@libsql/linux-arm-musleabihf@0.5.29': + optional: true + + '@libsql/linux-arm64-gnu@0.5.29': + optional: true + + '@libsql/linux-arm64-musl@0.5.29': + optional: true + + '@libsql/linux-x64-gnu@0.5.29': + optional: true + + '@libsql/linux-x64-musl@0.5.29': + optional: true + + '@libsql/win32-x64-msvc@0.5.29': + optional: true + '@lukeed/csprng@1.1.0': {} '@lukeed/uuid@2.0.1': @@ -1196,12 +1404,36 @@ snapshots: - zod-openapi - zod-to-json-schema + '@mastra/libsql@1.23.2(@mastra/core@1.70.0(zod@4.6.5))': + dependencies: + '@libsql/client': 0.18.0 + '@mastra/core': 1.70.0(zod@4.6.5) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@mastra/memory@1.32.0(@mastra/core@1.70.0(zod@4.6.5))': + dependencies: + '@mastra/core': 1.70.0(zod@4.6.5) + '@mastra/schema-compat': 1.3.11(zod@4.6.5) + async-mutex: 0.5.0 + diff: 8.0.4 + json-schema: 0.4.0 + lru-cache: 11.5.3 + probe-image-size: 7.4.0 + xxhash-wasm: 1.1.0 + zod: 4.6.5 + transitivePeerDependencies: + - supports-color + '@mastra/schema-compat@1.3.11(zod@4.6.5)': dependencies: json-schema-to-zod: 2.8.1 zod: 4.6.5 zod-from-json-schema: 0.5.6 + '@neon-rs/load@0.0.4': {} + '@posthog/core@1.55.2': dependencies: '@posthog/types': 1.412.4 @@ -1252,6 +1484,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.6.2 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -1324,6 +1560,10 @@ snapshots: dependencies: sprintf-js: 1.0.3 + async-mutex@0.5.0: + dependencies: + tslib: 2.8.1 + bail@2.0.2: {} better-sqlite3@13.0.3: @@ -1356,6 +1596,14 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -1366,10 +1614,14 @@ snapshots: dequal@2.0.3: {} + detect-libc@2.0.2: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 + diff@8.0.4: {} + dotenv@17.4.2: {} esbuild@0.28.2: @@ -1487,6 +1739,10 @@ snapshots: human-signals@8.0.1: {} + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + ignore@7.0.10: {} is-extendable@0.1.1: {} @@ -1505,6 +1761,8 @@ snapshots: jpeg-js@0.4.4: {} + js-base64@3.9.4: {} + js-yaml@3.15.2: dependencies: argparse: 1.0.10 @@ -1516,6 +1774,23 @@ snapshots: kind-of@6.0.3: {} + libsql@0.5.29: + dependencies: + '@neon-rs/load': 0.0.4 + detect-libc: 2.0.2 + optionalDependencies: + '@libsql/darwin-arm64': 0.5.29 + '@libsql/darwin-x64': 0.5.29 + '@libsql/linux-arm-gnueabihf': 0.5.29 + '@libsql/linux-arm-musleabihf': 0.5.29 + '@libsql/linux-arm64-gnu': 0.5.29 + '@libsql/linux-arm64-musl': 0.5.29 + '@libsql/linux-x64-gnu': 0.5.29 + '@libsql/linux-x64-musl': 0.5.29 + '@libsql/win32-x64-msvc': 0.5.29 + + lodash.merge@4.6.2: {} + longest-streak@3.1.0: {} lru-cache@11.5.3: {} @@ -1815,8 +2090,18 @@ snapshots: transitivePeerDependencies: - supports-color + ms@2.0.0: {} + ms@2.1.3: {} + needle@2.9.1: + dependencies: + debug: 3.2.7 + iconv-lite: 0.4.24 + sax: 1.6.1 + transitivePeerDependencies: + - supports-color + node-addon-api@8.9.2: {} node-fetch@2.7.0: @@ -1852,6 +2137,16 @@ snapshots: dependencies: parse-ms: 4.0.0 + probe-image-size@7.4.0: + dependencies: + lodash.merge: 4.6.2 + needle: 2.9.1 + stream-parser: 0.3.1 + transitivePeerDependencies: + - supports-color + + promise-limit@2.7.0: {} + quansync@0.2.11: {} remark-gfm@4.0.1: @@ -1884,6 +2179,10 @@ snapshots: reusify@1.1.0: {} + safer-buffer@2.1.2: {} + + sax@1.6.1: {} + section-matter@1.0.0: dependencies: extend-shallow: 2.0.1 @@ -1899,6 +2198,12 @@ snapshots: sprintf-js@1.0.3: {} + stream-parser@0.3.1: + dependencies: + debug: 2.6.9 + transitivePeerDependencies: + - supports-color + strip-bom-string@1.0.0: {} strip-final-newline@4.0.0: {} @@ -1907,6 +2212,8 @@ snapshots: trough@2.2.0: {} + tslib@2.8.1: {} + tsx@4.23.15: dependencies: esbuild: 0.28.2 diff --git a/prompts/system.md b/prompts/system.md index 3ba7203..aa6e52e 100644 --- a/prompts/system.md +++ b/prompts/system.md @@ -3,6 +3,9 @@ Будь кратким, если не просят подробностей. Обращайся к людям по имени, а не по нику. Если говоришь о другом участнике — тоже называй его по имени. +Тебе пишут разные люди в одном диалоге. Каждая реплика пользователя обёрнута в тег +… — это тот, кто спрашивает. Сам тег в ответе не повторяй. + Память: - Предыдущие вопросы к тебе и твои ответы в этом чате уже есть выше в диалоге — это твой контекст. - Остальная переписка чата лежит в архиве — читай её инструментами, когда нужно. diff --git a/src/bot/create-bot.ts b/src/bot/create-bot.ts index 285dcab..1281fca 100644 --- a/src/bot/create-bot.ts +++ b/src/bot/create-bot.ts @@ -2,7 +2,6 @@ import { Bot, type Context } from "grammy"; import type { AssistantService } from "../services/assistant/assistant-service.js"; import type { ChatHistoryService } from "../services/chat-history-service.js"; import type { ChatMemberService } from "../services/chat-member-service.js"; -import type { DialogService } from "../services/dialog-service.js"; import type { HistoryImportService } from "../services/history-import-service.js"; import type { PersonaService } from "../services/persona-service.js"; import { createContextHandlers } from "./handlers/context-handlers.js"; @@ -18,7 +17,6 @@ interface BotDeps { personaService: PersonaService; chatHistoryService: ChatHistoryService; chatMemberService: ChatMemberService; - dialogService: DialogService; historyImportService: HistoryImportService; assistantService: AssistantService; } @@ -29,7 +27,6 @@ export const createBot = ({ personaService, chatHistoryService, chatMemberService, - dialogService, historyImportService, assistantService, }: BotDeps): Bot => { @@ -45,7 +42,7 @@ export const createBot = ({ admin.use(createHelpHandlers()); admin.use(createPersonaHandlers(personaService)); - admin.use(createContextHandlers({ chatHistoryService, chatMemberService, dialogService })); + admin.use(createContextHandlers({ chatHistoryService, chatMemberService, assistantService })); admin.use(createImportHandlers(historyImportService)); admin.use(createQuestionHandlers({ assistantService, personaService, chatHistoryService, chatMemberService })); diff --git a/src/bot/handlers/context-handlers.ts b/src/bot/handlers/context-handlers.ts index e6ca404..ea7c1e4 100644 --- a/src/bot/handlers/context-handlers.ts +++ b/src/bot/handlers/context-handlers.ts @@ -2,12 +2,12 @@ import { Composer, type Context } from "grammy"; import { formatUtc } from "../../libs/time.js"; import type { ChatHistoryService } from "../../services/chat-history-service.js"; import type { ChatMemberService } from "../../services/chat-member-service.js"; -import type { DialogService } from "../../services/dialog-service.js"; +import type { AssistantService } from "../../services/assistant/assistant-service.js"; interface ContextHandlersDeps { chatHistoryService: ChatHistoryService; chatMemberService: ChatMemberService; - dialogService: DialogService; + assistantService: AssistantService; } /** Видит ли бот все сообщения группы: privacy mode выключен или бот — админ группы. */ @@ -26,18 +26,18 @@ const visibilityText = async (ctx: Context): Promise => { }; /** Что бот помнит о чате: контекст диалога и архив переписки. Подключать только за фильтром администратора. */ -export const createContextHandlers = ({ chatHistoryService, chatMemberService, dialogService }: ContextHandlersDeps) => { +export const createContextHandlers = ({ chatHistoryService, chatMemberService, assistantService }: ContextHandlersDeps) => { const composer = new Composer(); composer.command("context", async (ctx) => { - const dialog = dialogService.stats(ctx.chat.id); + const dialogMessages = await assistantService.dialogSize(ctx.chat.id); const archive = chatHistoryService.stats(ctx.chat.id); const members = chatMemberService.list(ctx.chat.id).length; const since = archive.oldest === null ? "" : `, с ${formatUtc(archive.oldest)}`; const lines = [ - `Контекст диалога: ${dialog.exchanges} из ${dialog.exchangesLimit} последних вопросов и ответов. Сбросить — /clear.`, + `Контекст диалога: ${dialogMessages} сообщений (в запрос идут последние, сколько влезает в бюджет токенов). Сбросить — /clear.`, `Архив чата: ${archive.count} сообщений из ${archive.limit} возможных${since}. Читаю его, когда нужно.`, `Знаю участников: ${members}.`, await visibilityText(ctx), @@ -49,12 +49,9 @@ export const createContextHandlers = ({ chatHistoryService, chatMemberService, d }); composer.command("clear", async (ctx) => { - const forgotten = dialogService.clear(ctx.chat.id); + await assistantService.clearDialog(ctx.chat.id); - await ctx.reply( - `Контекст диалога сброшен (забыл ${forgotten} вопросов и ответов), начинаю с чистого листа. ` + - "Архив переписки чата на месте.", - ); + await ctx.reply("Контекст диалога сброшен, начинаю с чистого листа. Архив переписки чата на месте."); }); return composer; diff --git a/src/bot/handlers/question-handlers.ts b/src/bot/handlers/question-handlers.ts index e7e4dff..5fbc517 100644 --- a/src/bot/handlers/question-handlers.ts +++ b/src/bot/handlers/question-handlers.ts @@ -77,7 +77,7 @@ export const createQuestionHandlers = ({ repliedTo: repliedToMessage(msg), mentionedMembers: members, unknownUsernames, - imageSources: imageRefs.map((ref) => ref.source), + hasImages: imageRefs.length > 0, images, }; diff --git a/src/config.ts b/src/config.ts index 2ac8123..21a0320 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,5 @@ +import { dirname, join } from "node:path"; + /** Значение переменной окружения без пробелов; undefined — если не задана или пустая. */ const envValue = (name: string): string | undefined => process.env[name]?.trim() || undefined; @@ -89,6 +91,9 @@ const parseModel = (): ModelChoice => { /** Путь к SQLite; нужен боту и скриптам (`seed`, импорт), которым остальные переменные не нужны. */ export const DB_PATH = envValue("DB_PATH") ?? "data/bot.db"; +/** Хранилище Mastra Memory (контекст диалога) — отдельный файл рядом с основной базой. */ +export const MEMORY_DB_PATH = join(dirname(DB_PATH), "memory.db"); + /** Сколько последних сообщений каждого чата хранить в базе; нужен боту и скрипту импорта. */ export const HISTORY_LIMIT = positiveInt("HISTORY_LIMIT", 5000); @@ -96,6 +101,8 @@ export const loadBotConfig = () => ({ botToken: required("BOT_TOKEN"), adminIds: parseAdminIds(required("ADMIN_IDS")), ai: parseModel(), - /** Сколько последних пар «вопрос — ответ» модель помнит в каждом чате. */ - dialogLimit: positiveInt("DIALOG_LIMIT", 10), + /** Бюджет токенов на весь промпт; сверх него Mastra Memory срезает самые старые реплики диалога. */ + contextTokenLimit: positiveInt("CONTEXT_TOKEN_LIMIT", 12_000), + /** Потолок длины ответа в токенах — длиннее Telegram всё равно режет на несколько сообщений. */ + answerTokenLimit: positiveInt("ANSWER_TOKEN_LIMIT", 1_500), }); diff --git a/src/index.ts b/src/index.ts index e903570..ed86f83 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,15 @@ import { BOT_COMMANDS } from "./bot/commands.js"; import { createBot } from "./bot/create-bot.js"; -import { DB_PATH, HISTORY_LIMIT, loadBotConfig } from "./config.js"; +import { DB_PATH, HISTORY_LIMIT, MEMORY_DB_PATH, loadBotConfig } from "./config.js"; import { createDb } from "./libs/db.js"; import { createChatMemberRepository } from "./repositories/chat-member-repository.js"; import { createChatSettingsRepository } from "./repositories/chat-settings-repository.js"; -import { createDialogRepository } from "./repositories/dialog-repository.js"; import { createMessageRepository } from "./repositories/message-repository.js"; import { createPersonaRepository } from "./repositories/persona-repository.js"; import { createAssistantService } from "./services/assistant/assistant-service.js"; +import { createAssistantMemory } from "./services/assistant/memory.js"; import { createChatHistoryService } from "./services/chat-history-service.js"; import { createChatMemberService } from "./services/chat-member-service.js"; -import { createDialogService } from "./services/dialog-service.js"; import { createHistoryImportService } from "./services/history-import-service.js"; import { createPersonaService } from "./services/persona-service.js"; @@ -33,11 +32,6 @@ const chatHistoryService = createChatHistoryService({ messageRepository, history const chatMemberService = createChatMemberService({ chatMemberRepository }); -const dialogService = createDialogService({ - dialogRepository: createDialogRepository(db), - exchangesLimit: config.dialogLimit, -}); - const historyImportService = createHistoryImportService({ messageRepository, chatMemberRepository, @@ -46,10 +40,11 @@ const historyImportService = createHistoryImportService({ const assistantService = createAssistantService({ model: config.ai.model, + memory: createAssistantMemory({ path: MEMORY_DB_PATH, maxTokens: config.contextTokenLimit }), imageInput: config.ai.imageInput, + answerTokenLimit: config.answerTokenLimit, chatHistoryService, chatMemberService, - dialogService, }); const bot = createBot({ @@ -58,7 +53,6 @@ const bot = createBot({ personaService, chatHistoryService, chatMemberService, - dialogService, historyImportService, assistantService, }); diff --git a/src/libs/db.ts b/src/libs/db.ts index 82d3243..7e5318f 100644 --- a/src/libs/db.ts +++ b/src/libs/db.ts @@ -35,17 +35,6 @@ const SCHEMA = ` last_name TEXT, PRIMARY KEY (chat_id, user_id) ); - - -- Контекст диалога с ботом (вопросы и ответы) — отдельно от архива чата, /clear чистит только его. - CREATE TABLE IF NOT EXISTS dialog_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - chat_id INTEGER NOT NULL, - role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), - content TEXT NOT NULL, - created_at INTEGER NOT NULL - ); - - CREATE INDEX IF NOT EXISTS dialog_by_chat ON dialog_messages (chat_id, id); `; const hasColumn = (db: Db, table: string, column: string): boolean => @@ -58,8 +47,13 @@ const migrate = (db: Db): void => { db.exec("CREATE INDEX IF NOT EXISTS messages_by_user ON messages (chat_id, user_id, message_id)"); }; -export const createDb = (path: string): Db => { +/** Создаёт папку для файла базы; для `:memory:` ничего не делает. */ +export const ensureParentDir = (path: string): void => { if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true }); +}; + +export const createDb = (path: string): Db => { + ensureParentDir(path); const db = new Database(path); diff --git a/src/repositories/dialog-repository.ts b/src/repositories/dialog-repository.ts deleted file mode 100644 index abc5b6b..0000000 --- a/src/repositories/dialog-repository.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Db } from "../libs/db.js"; -import type { DialogTurn } from "../types.js"; - -export const createDialogRepository = (db: Db) => { - const statements = { - insert: db.prepare("INSERT INTO dialog_messages (chat_id, role, content, created_at) VALUES (?, ?, ?, ?)"), - pruneChat: db.prepare(` - DELETE FROM dialog_messages WHERE chat_id = ? AND id <= ( - SELECT id FROM dialog_messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1 OFFSET ? - ) - `), - latest: db.prepare("SELECT role, content FROM dialog_messages WHERE chat_id = ? ORDER BY id DESC LIMIT ?"), - countByChat: db.prepare("SELECT COUNT(*) AS count FROM dialog_messages WHERE chat_id = ?"), - deleteByChat: db.prepare("DELETE FROM dialog_messages WHERE chat_id = ?"), - }; - - /** - * Записывает вопрос и ответ парой и оставляет в чате не больше `keep` последних реплик. - * Пары пишутся атомарно, поэтому при чётном `keep` контекст всегда начинается с вопроса. - */ - const appendExchangeAndPrune = db.transaction((chatId: number, question: string, answer: string, keep: number) => { - const now = Math.floor(Date.now() / 1000); - - statements.insert.run(chatId, "user", question, now); - statements.insert.run(chatId, "assistant", answer, now); - statements.pruneChat.run(chatId, chatId, keep); - }); - - /** Последние `limit` реплик диалога в хронологическом порядке. */ - const latest = (chatId: number, limit: number): DialogTurn[] => - (statements.latest.all(chatId, limit) as DialogTurn[]).reverse(); - - const countByChat = (chatId: number): number => (statements.countByChat.get(chatId) as { count: number }).count; - - /** Забыть диалог чата; возвращает, сколько реплик удалено. */ - const deleteByChat = (chatId: number): number => statements.deleteByChat.run(chatId).changes; - - return { appendExchangeAndPrune, latest, countByChat, deleteByChat }; -}; - -export type DialogRepository = ReturnType; diff --git a/src/services/assistant/assistant-service.ts b/src/services/assistant/assistant-service.ts index 1b183d9..1b111f3 100644 --- a/src/services/assistant/assistant-service.ts +++ b/src/services/assistant/assistant-service.ts @@ -1,58 +1,72 @@ import { Agent } from "@mastra/core/agent"; +import { ToolCallFilter } from "@mastra/core/processors"; import { RequestContext } from "@mastra/core/request-context"; +import type { Memory } from "@mastra/memory"; import { readPrompt } from "../../libs/prompts.js"; -import type { DialogTurn, Persona } from "../../types.js"; +import type { Persona } from "../../types.js"; import type { ChatHistoryService } from "../chat-history-service.js"; import type { ChatMemberService } from "../chat-member-service.js"; -import type { DialogService } from "../dialog-service.js"; +import { chatThreadId } from "./memory.js"; import { buildInstructions, buildUserText, type Question } from "./prompts.js"; import { createListChatMembersTool } from "./tools/list-chat-members-tool.js"; import { createReadChatMessagesTool } from "./tools/read-chat-messages-tool.js"; import { createReadMemberMessagesTool } from "./tools/read-member-messages-tool.js"; -import type { AssistantRequestContext } from "./tools/request-context.js"; +import { assistantRequestContextSchema, type AssistantRequestContext } from "./tools/request-context.js"; export type { Question } from "./prompts.js"; interface AssistantServiceDeps { model: string; + /** Контекст диалога — Mastra Memory (см. memory.ts). */ + memory: Memory; + /** Максимальная длина ответа в токенах. */ + answerTokenLimit: number; /** Умеет ли модель смотреть изображения; если нет — фото ей не отправляем. */ imageInput: boolean; chatHistoryService: ChatHistoryService; chatMemberService: ChatMemberService; - dialogService: DialogService; } -const MAX_AGENT_STEPS = 6; +/** Хватает на «прочитать архив/участников → ответить»; каждый лишний шаг — ещё один полный промпт. */ +const MAX_AGENT_STEPS = 4; -/** Сообщение пользователя для модели: текст вопроса и скачанные картинки. */ -const buildUserMessage = (question: Question) => ({ - role: "user" as const, - content: [ - { type: "text" as const, text: buildUserText(question) }, - ...question.images.map((image) => ({ type: "image" as const, image: image.data, mimeType: image.mimeType })), - ], -}); - -/** Реплика прошлого диалога в формате сообщений Mastra (роль должна быть литералом). */ -const toModelMessage = (turn: DialogTurn) => - turn.role === "user" - ? { role: "user" as const, content: turn.content } - : { role: "assistant" as const, content: turn.content }; +/** + * Картинки уходят через `context`: модель видит их в этом запросе, но в память они не сохраняются — + * иначе байты фото тащились бы в каждый следующий вопрос. + */ +const imagesContext = (question: Question) => + question.images.length === 0 + ? [] + : [ + { + role: "user" as const, + content: question.images.map((image) => ({ type: "image" as const, image: image.data, mimeType: image.mimeType })), + }, + ]; export const createAssistantService = ({ model, + memory, + answerTokenLimit, imageInput, chatHistoryService, chatMemberService, - dialogService, }: AssistantServiceDeps) => { const systemRules = readPrompt("system.md"); const agent = new Agent({ id: "boltun", name: "Boltun", - instructions: systemRules, + requestContextSchema: assistantRequestContextSchema, + // Одинаковы для всех вопросов чата — провайдер может кешировать этот префикс. + instructions: ({ requestContext }) => + buildInstructions(systemRules, requestContext.get("persona"), requestContext.get("chatTitle")), model, + memory, + defaultOptions: { maxSteps: MAX_AGENT_STEPS, modelSettings: { maxOutputTokens: answerTokenLimit } }, + // Результаты инструментов (сотни сообщений архива) не должны ездить в каждый запрос: + // первый фильтр убирает их из прошлых реплик, второй внутри ответа оставляет только самый свежий шаг. + inputProcessors: [new ToolCallFilter(), new ToolCallFilter({ filterAfterToolSteps: 1 })], tools: { readChatMessages: createReadChatMessagesTool(chatHistoryService), listChatMembers: createListChatMembersTool(chatMemberService), @@ -61,32 +75,36 @@ export const createAssistantService = ({ }); /** - * Ответ на вопрос в образе личности. Контекст диалога (прошлые вопросы и ответы) подкладываем сами, - * архив чата агент подгружает инструментами, когда нужно. + * Ответ на вопрос в образе личности. Прошлые вопросы и ответы чата подтягивает Mastra Memory, + * архив переписки агент читает инструментами, когда нужно. */ const answer = async (question: Question, persona: Persona): Promise => { const requestContext = new RequestContext(); requestContext.set("chatId", question.chatId); + requestContext.set("chatTitle", question.chatTitle); + requestContext.set("persona", persona); - const history = dialogService.recent(question.chatId).map(toModelMessage); + const thread = chatThreadId(question.chatId); - const result = await agent.generate([...history, buildUserMessage(question)], { - instructions: buildInstructions(systemRules, question, persona), + const result = await agent.generate(buildUserText(question), { + context: imagesContext(question), + memory: { thread, resource: thread }, requestContext, - maxSteps: MAX_AGENT_STEPS, }); - const text = result.text.trim(); - - // Картинки в контекст не кладём — только текст вопроса; пустой ответ не запоминаем. - if (text) dialogService.remember(question.chatId, buildUserText(question), text); - - return text; + return result.text.trim(); }; + /** Сколько сообщений в контексте диалога чата: recall отдаёт `total`, не загружая весь тред. */ + const dialogSize = async (chatId: number): Promise => + (await memory.recall({ threadId: chatThreadId(chatId), perPage: 1 })).total; + + /** Начать диалог с чистого листа: удаляем тред чата из Memory. Архив чата не трогаем. */ + const clearDialog = (chatId: number): Promise => memory.deleteThread(chatThreadId(chatId)); + /** Боту стоит скачивать фото, только если модель умеет их смотреть. */ - return { answer, canSeeImages: imageInput }; + return { answer, dialogSize, clearDialog, canSeeImages: imageInput }; }; export type AssistantService = ReturnType; diff --git a/src/services/assistant/memory.ts b/src/services/assistant/memory.ts new file mode 100644 index 0000000..ba6d0fe --- /dev/null +++ b/src/services/assistant/memory.ts @@ -0,0 +1,30 @@ +import { LibSQLStore } from "@mastra/libsql"; +import { Memory } from "@mastra/memory"; +import { ensureParentDir } from "../../libs/db.js"; + +interface AssistantMemoryOptions { + path: string; + /** Бюджет токенов на весь промпт: сверх него Mastra срезает самые старые реплики диалога. */ + maxTokens: number; +} + +/** + * Контекст диалога с ботом — Mastra Memory: тред на каждый чат, только история сообщений. + * Архив переписки чата живёт отдельно (SQLite бота) и читается инструментами. + */ +export const createAssistantMemory = ({ path, maxTokens }: AssistantMemoryOptions): Memory => { + ensureParentDir(path); + + return new Memory({ + storage: new LibSQLStore({ id: "boltun-memory", url: `file:${path}` }), + options: { + messageHistory: { maxTokens }, + semanticRecall: false, + workingMemory: { enabled: false }, + generateTitle: false, + }, + }); +}; + +/** Один тред на чат: вопросы разных админов и ответы бота — общий диалог (шаблон Mastra для групповых чатов). */ +export const chatThreadId = (chatId: number): string => `chat:${chatId}`; diff --git a/src/services/assistant/prompts.ts b/src/services/assistant/prompts.ts index dff95e8..26ed8b6 100644 --- a/src/services/assistant/prompts.ts +++ b/src/services/assistant/prompts.ts @@ -15,8 +15,8 @@ export interface Question { mentionedMembers: ChatMember[]; /** Упомянутые ники, которых бот в чате не встречал. */ unknownUsernames: string[]; - /** Где в вопросе есть картинки — даже если модели их не отправляем. */ - imageSources: ImageSource[]; + /** Есть ли в вопросе картинки — даже если модели их не отправляем. */ + hasImages: boolean; /** Скачанные картинки для модели; пусто, если модель их не видит или скачать не вышло. */ images: ImageAttachment[]; } @@ -27,45 +27,47 @@ const IMAGE_SOURCES: Record = { }; /** Подсказка модели, откуда картинки — или что посмотреть их она не может. */ -const imagesNote = ({ imageSources, images }: Question): string | undefined => { - if (imageSources.length === 0) return undefined; +const imagesNote = ({ hasImages, images }: Question): string | undefined => { + if (!hasImages) return undefined; if (images.length === 0) return "К сообщению приложено изображение, но ты не можешь его просмотреть — честно скажи об этом."; return `Приложены изображения (по порядку): ${images.map((image) => IMAGE_SOURCES[image.source]).join(", ")}.`; }; -const mentionsSection = (question: Question): string | undefined => { - const known = question.mentionedMembers.length > 0 ? `Упомянуты участники:\n${formatChatMembers(question.mentionedMembers)}` : ""; +const mentionsNote = ({ mentionedMembers, unknownUsernames }: Question): string | undefined => { + const known = mentionedMembers.length > 0 ? `Упомянуты участники:\n${formatChatMembers(mentionedMembers)}` : ""; const unknown = - question.unknownUsernames.length > 0 - ? `Эти ники в чате не встречались: ${question.unknownUsernames.map((u) => `@${u}`).join(", ")}.` - : ""; + unknownUsernames.length > 0 ? `Эти ники в чате не встречались: ${unknownUsernames.map((u) => `@${u}`).join(", ")}.` : ""; return [known, unknown].filter(Boolean).join("\n") || undefined; }; -/** Системные правила бота из `prompts/system.md` + личность + контекст конкретного вопроса. */ -export const buildInstructions = (systemRules: string, question: Question, persona: Persona): string => - [ - `Твоя личность: ${persona.name}.\n${persona.prompt}`, - systemRules, - `Чат: «${question.chatTitle}». Вопрос задаёт ${question.askerName}.`, - mentionsSection(question), - ] - .filter(Boolean) - .join("\n\n"); +/** + * Системный промпт: одинаков для всех вопросов в чате (правила → личность → чат), + * поэтому провайдер может его кешировать. Всё, что про конкретный вопрос, — в реплике пользователя. + */ +export const buildInstructions = (systemRules: string, persona: Persona, chatTitle: string): string => + [systemRules, `Твоя личность: ${persona.name}.\n${persona.prompt}`, `Чат: «${chatTitle}».`].join("\n\n"); -export const buildPrompt = (question: Question): string => { +const escapeAttr = (value: string): string => + value.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); + +/** + * Реплика пользователя в формате Mastra для общих тредов: ``. + * Тег сохраняется в памяти, поэтому и в следующих вопросах модель видит, кто что спрашивал. + */ +export const buildUserText = (question: Question): string => { const replied = question.repliedTo ? `Вопрос задан в ответ на сообщение:\n${formatChatMessages([question.repliedTo])}` : undefined; const asked = question.text || "(текста нет — прислали только изображение, опиши и прокомментируй его)"; - return [replied, imagesNote(question), replied ? `Вопрос: ${asked}` : asked].filter(Boolean).join("\n\n"); -}; + const body = [replied, mentionsNote(question), imagesNote(question), replied ? `Вопрос: ${asked}` : asked] + .filter(Boolean) + .join("\n\n"); -/** Текст реплики пользователя — с именем, потому что в чате боту пишут разные админы. */ -export const buildUserText = (question: Question): string => `${question.askerName}:\n${buildPrompt(question)}`; + return `\n${body}\n`; +}; diff --git a/src/services/assistant/tools/request-context.ts b/src/services/assistant/tools/request-context.ts index 0661ea0..90e2cd9 100644 --- a/src/services/assistant/tools/request-context.ts +++ b/src/services/assistant/tools/request-context.ts @@ -1,6 +1,10 @@ import { z } from "zod"; -/** Что каждый инструмент агента получает от бота: в каком чате задан вопрос. */ -export const assistantRequestContextSchema = z.object({ chatId: z.number() }); +/** Что агент и его инструменты получают от бота на каждый вопрос. */ +export const assistantRequestContextSchema = z.object({ + chatId: z.number(), + chatTitle: z.string(), + persona: z.object({ id: z.string(), name: z.string(), prompt: z.string() }), +}); export type AssistantRequestContext = z.infer; diff --git a/src/services/dialog-service.ts b/src/services/dialog-service.ts deleted file mode 100644 index a863078..0000000 --- a/src/services/dialog-service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { DialogRepository } from "../repositories/dialog-repository.js"; -import type { DialogTurn } from "../types.js"; - -interface DialogServiceDeps { - dialogRepository: DialogRepository; - /** Сколько последних пар «вопрос — ответ» модель видит в каждом запросе. */ - exchangesLimit: number; -} - -/** Контекст диалога с ботом — то, что модель «помнит» между вопросами. Архив чата живёт отдельно. */ -export const createDialogService = ({ dialogRepository, exchangesLimit }: DialogServiceDeps) => { - const turnsLimit = exchangesLimit * 2; - - const recent = (chatId: number): DialogTurn[] => dialogRepository.latest(chatId, turnsLimit); - - const remember = (chatId: number, question: string, answer: string): void => - dialogRepository.appendExchangeAndPrune(chatId, question, answer, turnsLimit); - - const stats = (chatId: number) => ({ exchanges: Math.floor(dialogRepository.countByChat(chatId) / 2), exchangesLimit }); - - /** Начать диалог с чистого листа; архив сообщений чата не трогаем. Возвращает, сколько пар забыто. */ - const clear = (chatId: number): number => Math.floor(dialogRepository.deleteByChat(chatId) / 2); - - return { recent, remember, stats, clear }; -}; - -export type DialogService = ReturnType; diff --git a/src/types.ts b/src/types.ts index 87e6cd1..7ef1a01 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,9 +42,3 @@ export interface ImageAttachment { mimeType: string; source: ImageSource; } - -/** Реплика диалога с ботом: вопрос пользователя или ответ бота. */ -export interface DialogTurn { - role: "user" | "assistant"; - content: string; -}