Контекст диалога на Mastra Memory вместо своей таблицы

- Контекст диалога хранит Mastra Memory (LibSQL, memory.db рядом с DB_PATH), тред на чат
- Бюджет истории — messageHistory.maxTokens (CONTEXT_TOKEN_LIMIT, по умолчанию 12000)
- ToolCallFilter: результаты инструментов не попадают в прошлые реплики, внутри ответа — только свежий шаг
- Системный промпт стабилен для всего чата (кешируется провайдером); автор и упоминания — в <turn author_name>
- Фото передаются через 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 <noreply@anthropic.com>
This commit is contained in:
yunogasai
2026-09-24 20:55:06 +02:00
parent b8f09227b0
commit b615518f22
19 changed files with 464 additions and 179 deletions

View File

@@ -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

View File

@@ -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/<id>.md
```
- Память разделена: архив чата (`messages`, читается инструментами) и контекст диалога (`dialog_messages`, подкладывается в каждый запрос). `/clear` чистит только контекст.
- Память разделена: архив чата (`messages` в SQLite бота, читается инструментами) и контекст диалога (Mastra Memory, `data/memory.db`, тред на чат). `/clear` чистит только контекст.
- Где Mastra закрывает потребность (память, бюджет токенов, фильтры, процессоры) — используем Mastra, а не своё.
- Промпты храним только в `prompts/*.md`, не в коде.
- Личности не редактируются из чата: источник правды — код и `prompts/`, в БД их кладёт `seed`.
- Изменения схемы БД — через `migrate` в `libs/db.ts` (у пользователей уже есть базы).

View File

@@ -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)

View File

@@ -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"

307
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -3,6 +3,9 @@
Будь кратким, если не просят подробностей.
Обращайся к людям по имени, а не по нику. Если говоришь о другом участнике — тоже называй его по имени.
Тебе пишут разные люди в одном диалоге. Каждая реплика пользователя обёрнута в тег
<turn author_name="Имя">…</turn> — это тот, кто спрашивает. Сам тег в ответе не повторяй.
Память:
- Предыдущие вопросы к тебе и твои ответы в этом чате уже есть выше в диалоге — это твой контекст.
- Остальная переписка чата лежит в архиве — читай её инструментами, когда нужно.

View File

@@ -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 }));

View File

@@ -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<string> => {
};
/** Что бот помнит о чате: контекст диалога и архив переписки. Подключать только за фильтром администратора. */
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;

View File

@@ -77,7 +77,7 @@ export const createQuestionHandlers = ({
repliedTo: repliedToMessage(msg),
mentionedMembers: members,
unknownUsernames,
imageSources: imageRefs.map((ref) => ref.source),
hasImages: imageRefs.length > 0,
images,
};

View File

@@ -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),
});

View File

@@ -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,
});

View File

@@ -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);

View File

@@ -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<typeof createDialogRepository>;

View File

@@ -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<string> => {
const requestContext = new RequestContext<AssistantRequestContext>();
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<number> =>
(await memory.recall({ threadId: chatThreadId(chatId), perPage: 1 })).total;
/** Начать диалог с чистого листа: удаляем тред чата из Memory. Архив чата не трогаем. */
const clearDialog = (chatId: number): Promise<void> => memory.deleteThread(chatThreadId(chatId));
/** Боту стоит скачивать фото, только если модель умеет их смотреть. */
return { answer, canSeeImages: imageInput };
return { answer, dialogSize, clearDialog, canSeeImages: imageInput };
};
export type AssistantService = ReturnType<typeof createAssistantService>;

View File

@@ -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}`;

View File

@@ -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<ImageSource, string> = {
};
/** Подсказка модели, откуда картинки — или что посмотреть их она не может. */
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
/**
* Реплика пользователя в формате Mastra для общих тредов: `<turn author_name="…">`.
* Тег сохраняется в памяти, поэтому и в следующих вопросах модель видит, кто что спрашивал.
*/
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 `<turn author_name="${escapeAttr(question.askerName)}">\n${body}\n</turn>`;
};

View File

@@ -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<typeof assistantRequestContextSchema>;

View File

@@ -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<typeof createDialogService>;

View File

@@ -42,9 +42,3 @@ export interface ImageAttachment {
mimeType: string;
source: ImageSource;
}
/** Реплика диалога с ботом: вопрос пользователя или ответ бота. */
export interface DialogTurn {
role: "user" | "assistant";
content: string;
}