@ykurilov/agent-chat-core 0.0.0-stage → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/CHANGELOG.md +2259 -0
  2. package/LICENSE +21 -0
  3. package/README.md +145 -2
  4. package/THIRD_PARTY_NOTICES.md +44 -0
  5. package/dist/access.js +10 -0
  6. package/dist/access.js.map +1 -0
  7. package/dist/chunk-PWGI6NH6.js +56 -0
  8. package/dist/chunk-PWGI6NH6.js.map +1 -0
  9. package/dist/chunk-PWW3R3VL.js +12931 -0
  10. package/dist/chunk-PWW3R3VL.js.map +1 -0
  11. package/dist/chunk-SNPF4BPB.js +20 -0
  12. package/dist/chunk-SNPF4BPB.js.map +1 -0
  13. package/dist/index.js +974 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/storage-contract.js +2596 -0
  16. package/dist/storage-contract.js.map +1 -0
  17. package/dist/types/access/chat-capabilities.d.ts +38 -0
  18. package/dist/types/access/chat-closure.d.ts +16 -0
  19. package/dist/types/access/chat-creation.d.ts +20 -0
  20. package/dist/types/access/client-visibility.d.ts +41 -0
  21. package/dist/types/access/resolved-user.d.ts +24 -0
  22. package/dist/types/agent-run/answer-liveness.d.ts +16 -0
  23. package/dist/types/agent-run/answer-saver.d.ts +41 -0
  24. package/dist/types/agent-run/auto-continue.d.ts +78 -0
  25. package/dist/types/agent-run/browser-tools.d.ts +64 -0
  26. package/dist/types/agent-run/build-model-request.d.ts +108 -0
  27. package/dist/types/agent-run/channel-notes.d.ts +39 -0
  28. package/dist/types/agent-run/chat-closure-request.d.ts +21 -0
  29. package/dist/types/agent-run/data-changes.d.ts +14 -0
  30. package/dist/types/agent-run/end-turn.d.ts +59 -0
  31. package/dist/types/agent-run/greeting-lead.d.ts +15 -0
  32. package/dist/types/agent-run/json-repair.d.ts +6 -0
  33. package/dist/types/agent-run/last-step.d.ts +35 -0
  34. package/dist/types/agent-run/length-cut.d.ts +9 -0
  35. package/dist/types/agent-run/missing-closers.d.ts +16 -0
  36. package/dist/types/agent-run/mode-notes.d.ts +36 -0
  37. package/dist/types/agent-run/next-attempt.d.ts +106 -0
  38. package/dist/types/agent-run/observe-model-calls.d.ts +27 -0
  39. package/dist/types/agent-run/parts-to-chunks.d.ts +10 -0
  40. package/dist/types/agent-run/reasoning-timer.d.ts +21 -0
  41. package/dist/types/agent-run/repair-tool-call.d.ts +34 -0
  42. package/dist/types/agent-run/reply-check-types.d.ts +52 -0
  43. package/dist/types/agent-run/reply-checks.d.ts +110 -0
  44. package/dist/types/agent-run/run-agent-turn.d.ts +349 -0
  45. package/dist/types/agent-run/screen-place.d.ts +19 -0
  46. package/dist/types/agent-run/sequential-tools.d.ts +46 -0
  47. package/dist/types/agent-run/step-cache-mark.d.ts +29 -0
  48. package/dist/types/agent-run/step-context.d.ts +66 -0
  49. package/dist/types/agent-run/step-reset.d.ts +27 -0
  50. package/dist/types/agent-run/stray-closers.d.ts +13 -0
  51. package/dist/types/agent-run/today.d.ts +18 -0
  52. package/dist/types/agent-run/tool-call-gate.d.ts +21 -0
  53. package/dist/types/agent-run/tool-errors.d.ts +22 -0
  54. package/dist/types/agent-run/tool-result-limit.d.ts +23 -0
  55. package/dist/types/agent-run/tool-timeout.d.ts +48 -0
  56. package/dist/types/agent-run/turn-attempt.d.ts +39 -0
  57. package/dist/types/agent-run/turn-budget.d.ts +17 -0
  58. package/dist/types/agent-run/turn-finished.d.ts +17 -0
  59. package/dist/types/agent-run/turn-limits.d.ts +32 -0
  60. package/dist/types/agent-run/turn-observer.d.ts +71 -0
  61. package/dist/types/agent-run/turn-usage.d.ts +80 -0
  62. package/dist/types/agent-run/unanswered-messages.d.ts +10 -0
  63. package/dist/types/agent-run/undelivered.d.ts +25 -0
  64. package/dist/types/agent-run/voice-mode-texts.d.ts +32 -0
  65. package/dist/types/agent-run/voice-sounds.d.ts +11 -0
  66. package/dist/types/app-state/app-state.d.ts +71 -0
  67. package/dist/types/app-state/branch-state.d.ts +22 -0
  68. package/dist/types/app-state/state-changed.d.ts +53 -0
  69. package/dist/types/app-state/state-messages.d.ts +57 -0
  70. package/dist/types/attachments/message-attachments.d.ts +78 -0
  71. package/dist/types/background/background-tasks.d.ts +52 -0
  72. package/dist/types/bursts/message-bursts.d.ts +86 -0
  73. package/dist/types/chat-events/chat-events.d.ts +126 -0
  74. package/dist/types/chat-service.d.ts +915 -0
  75. package/dist/types/chat-turn-lease.d.ts +35 -0
  76. package/dist/types/compaction/answer-steps.d.ts +16 -0
  77. package/dist/types/compaction/compacted-history.d.ts +8 -0
  78. package/dist/types/compaction/compaction-plan.d.ts +48 -0
  79. package/dist/types/compaction/compaction-service.d.ts +113 -0
  80. package/dist/types/compaction/compaction-settings.d.ts +30 -0
  81. package/dist/types/compaction/compaction-task.d.ts +15 -0
  82. package/dist/types/compaction/run-compaction.d.ts +56 -0
  83. package/dist/types/conversation/abandoned-answers.d.ts +16 -0
  84. package/dist/types/conversation/answer-edit.d.ts +9 -0
  85. package/dist/types/conversation/branches.d.ts +30 -0
  86. package/dist/types/conversation/chat-title.d.ts +11 -0
  87. package/dist/types/conversation/deliverable-parts.d.ts +8 -0
  88. package/dist/types/conversation/edit-answer-parts.d.ts +25 -0
  89. package/dist/types/conversation/glue-fragments.d.ts +15 -0
  90. package/dist/types/conversation/hanging-tool-calls.d.ts +32 -0
  91. package/dist/types/conversation/load-current-branch.d.ts +6 -0
  92. package/dist/types/conversation/select-reasoning.d.ts +14 -0
  93. package/dist/types/conversation/to-agent-chat-message.d.ts +3 -0
  94. package/dist/types/conversation/token-usage.d.ts +6 -0
  95. package/dist/types/conversation/types.d.ts +257 -0
  96. package/dist/types/external-content/external-content.d.ts +40 -0
  97. package/dist/types/frozen-context/app-blocks.d.ts +16 -0
  98. package/dist/types/frozen-context/context-blocks.d.ts +104 -0
  99. package/dist/types/frozen-context/freeze-context.d.ts +32 -0
  100. package/dist/types/frozen-context/frozen-context-messages.d.ts +35 -0
  101. package/dist/types/frozen-context/index.d.ts +5 -0
  102. package/dist/types/frozen-context/turn-context.d.ts +26 -0
  103. package/dist/types/helpers/helper-approval.d.ts +43 -0
  104. package/dist/types/helpers/helper-tools.d.ts +37 -0
  105. package/dist/types/helpers/helper-types.d.ts +81 -0
  106. package/dist/types/helpers/run-helper.d.ts +58 -0
  107. package/dist/types/index.d.ts +128 -0
  108. package/dist/types/instructions/assemble-instructions.d.ts +23 -0
  109. package/dist/types/memory/long-term-memory.d.ts +78 -0
  110. package/dist/types/memory/memory-after-answer.d.ts +31 -0
  111. package/dist/types/memory/memory-optimization.d.ts +82 -0
  112. package/dist/types/models/catalog-changes.d.ts +18 -0
  113. package/dist/types/models/model-catalog.d.ts +295 -0
  114. package/dist/types/models/model-choice.d.ts +67 -0
  115. package/dist/types/models/profiles/claude-haiku-5-5.d.ts +2 -0
  116. package/dist/types/models/profiles/deepseek-4-1-flash.d.ts +2 -0
  117. package/dist/types/models/profiles/gemini-3-8-flash.d.ts +2 -0
  118. package/dist/types/models/profiles/glm-5-3-flash.d.ts +2 -0
  119. package/dist/types/models/profiles/gpt-6-luna.d.ts +2 -0
  120. package/dist/types/models/providers/deepseek.d.ts +4 -0
  121. package/dist/types/models/providers/fireworks.d.ts +4 -0
  122. package/dist/types/models/providers/openrouter-cache-mark-limit.d.ts +20 -0
  123. package/dist/types/models/providers/openrouter-gemini-cache-mark.d.ts +10 -0
  124. package/dist/types/models/providers/openrouter-reasoning-details.d.ts +9 -0
  125. package/dist/types/models/providers/openrouter.d.ts +97 -0
  126. package/dist/types/models/providers/provider-connection.d.ts +24 -0
  127. package/dist/types/models/providers/sdk-reasoning.d.ts +3 -0
  128. package/dist/types/models/providers/session-header.d.ts +7 -0
  129. package/dist/types/models/providers/together.d.ts +17 -0
  130. package/dist/types/models/reasoning-budget.d.ts +24 -0
  131. package/dist/types/models/reasoning-level.d.ts +6 -0
  132. package/dist/types/models/standard-catalog.d.ts +2 -0
  133. package/dist/types/notices/agent-notice.d.ts +105 -0
  134. package/dist/types/notices/deliver-notices.d.ts +10 -0
  135. package/dist/types/notices/notices-for-model.d.ts +14 -0
  136. package/dist/types/page-context/page-context.d.ts +31 -0
  137. package/dist/types/provider-errors/classify-provider-error.d.ts +8 -0
  138. package/dist/types/provider-errors/error-fields.d.ts +15 -0
  139. package/dist/types/provider-errors/error-signs.d.ts +6 -0
  140. package/dist/types/provider-errors/model-silence.d.ts +30 -0
  141. package/dist/types/provider-errors/provider-failure-log.d.ts +16 -0
  142. package/dist/types/provider-errors/transient-retry-middleware.d.ts +46 -0
  143. package/dist/types/request-origin/request-origin.d.ts +34 -0
  144. package/dist/types/running-answers/live-turns.d.ts +35 -0
  145. package/dist/types/running-answers/running-answer-registry.d.ts +20 -0
  146. package/dist/types/running-answers/turn-slots.d.ts +19 -0
  147. package/dist/types/spending/spending-limits.d.ts +161 -0
  148. package/dist/types/steering/turn-steering.d.ts +40 -0
  149. package/dist/types/storage/chat-data.d.ts +39 -0
  150. package/dist/types/storage/chat-storage.d.ts +372 -0
  151. package/dist/types/storage/in-memory-storage.d.ts +2 -0
  152. package/dist/types/storage/storage-contract.d.ts +36 -0
  153. package/dist/types/subject-chats/subject-chats.d.ts +123 -0
  154. package/dist/types/text/log-value.d.ts +2 -0
  155. package/dist/types/text/safe-truncate.d.ts +4 -0
  156. package/dist/types/text/scrub-secrets.d.ts +14 -0
  157. package/dist/types/tool-approval/approval-parts.d.ts +53 -0
  158. package/dist/types/tool-approval/tool-approval-policy.d.ts +14 -0
  159. package/dist/types/turn-hooks/after-turn.d.ts +74 -0
  160. package/dist/types/turn-hooks/app-events.d.ts +32 -0
  161. package/dist/types/turn-hooks/prepare-turn.d.ts +93 -0
  162. package/dist/types/turn-hooks/turn-gate.d.ts +62 -0
  163. package/dist/types/turn-hooks/turn-hooks.d.ts +172 -0
  164. package/dist/types/turn-hooks/turn-preparation.d.ts +27 -0
  165. package/dist/types/usage/ask-model.d.ts +18 -0
  166. package/dist/types/usage/cache-miss-watch.d.ts +55 -0
  167. package/dist/types/usage/peak-hours.d.ts +8 -0
  168. package/dist/types/usage/record-usage.d.ts +33 -0
  169. package/dist/types/usage/settle-generation-usage.d.ts +34 -0
  170. package/dist/types/usage/usage-cost.d.ts +27 -0
  171. package/dist/types/voice-tags.d.ts +18 -0
  172. package/dist/types/widgets/basic-widgets.d.ts +21 -0
  173. package/dist/types/widgets/define-widget.d.ts +6 -0
  174. package/dist/types/widgets/diagrams-instructions.d.ts +5 -0
  175. package/dist/types/widgets/live-demos-instructions.d.ts +5 -0
  176. package/dist/types/widgets/pending-widgets.d.ts +27 -0
  177. package/dist/types/widgets/widget-answer-check.d.ts +40 -0
  178. package/dist/types/widgets/widget-tools.d.ts +34 -0
  179. package/dist/types/widgets/widget-types.d.ts +118 -0
  180. package/package.json +63 -3
package/CHANGELOG.md ADDED
@@ -0,0 +1,2259 @@
1
+ # @ykurilov/agent-chat-core
2
+
3
+ ## 0.55.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 51f7b38: DeepSeek 4.1 Flash больше не пишет за собеседника: в профиле модели обрезка хвоста `sampling: { minP: 0.05 }`, она уходит
8
+ полем `min_p` к Together и OpenRouter (DeepSeek напрямую и Fireworks его не получают). Без неё Together изредка брал в
9
+ конце склеенное «?» с пустой строкой, и модель продолжала писать «user…», вторые рассуждения и новый ответ.
10
+
11
+ Новое в профиле модели: `sampling` (параметры выбора слов, пока `minP`) и у провайдера `samplingRequest(sampling)` —
12
+ перевод в опции запроса. Опции профиля (`providerOptions` и `sampling`) теперь несёт сама модель из каталога
13
+ (`resolveLanguageModel`): их получают и служебные вызовы с готовой моделью — склейка памяти после ответа раньше шла без
14
+ них (в том числе без запрета хозяев OpenRouter из профиля).
15
+
16
+ ### Как обновить приложение
17
+
18
+ - Ничего делать не нужно. Если приложение само добавляло DeepSeek 4.1 Flash `min_p` (`addModels` с
19
+ `providerOptions.togetherai.min_p` / `providerOptions.openrouter.min_p`) — эту правку можно убрать: SDK делает то же.
20
+ - Выключить или поменять: `addModels: [{ ...профиль, sampling: { minP: 0.1 } }]` или профиль без `sampling`; одному
21
+ провайдеру — `providerOptions: { togetherai: { min_p: … } }` в профиле (главнее `sampling`). Подробно —
22
+ docs/getting-started.md, «Параметры выбора слов в профиле модели».
23
+ - cdc7dd7: Голос: расход в лимитах, причина отказа, Next.js, язык записи, предел из настройки.
24
+
25
+ - `UsageSource` открыт для своих видов приложения (`"font_download"` без приведения типа), штатные — в
26
+ `BUILT_IN_USAGE_SOURCES`, тип `BuiltInUsageSource`. Новые штатные виды `voice_input` (секунды распознавания,
27
+ `inputTokens` — секунды) и `voice_polish` (чистка текста). Миграций нет.
28
+ - `createVoiceRelay({ spending: { service, recognitionUsdPerHour, chatId? } })`: лимит до диктовки, секунды звука в журнал
29
+ расхода. Чистка: `polish: { askModel, model }` через службу чата; у Together — `pricing`; своя `clean` может вернуть
30
+ `{ text, usage }`. Расход чистки — вид `voice_polish`.
31
+ - Причина отказа доходит до браузера кодом закрытия (`RELAY_CLOSE`): 4401 не вошёл, 4402 лимит, 4403 нет доступа,
32
+ 4413 слишком длинно, 4429 часто, 4502 недоступен, 1009 кусок больше предела. Отказ приложения `{ refused: { code } }`
33
+ переводится по `code`. Новые виды ошибок `limit`, `forbidden`, `too_long`; 4401 теперь `forbidden` (был
34
+ `service_unavailable`).
35
+ - `@ykurilov/agent-chat-voice/node`: `createVoiceUpgrade({ path, relay, maxFrameBytes })` — голос на своём HTTP-сервере
36
+ Node.js (Next.js custom server), `shareVoiceRelay` / `sharedVoiceRelay` — мостик внутри процесса. Нужен `ws`.
37
+ Кривой путь — 400, обрыв во время проверки входа не роняет процесс, `authorizeTimeoutMs` (10 с) — 503.
38
+ - Ретранслятор: `helloTimeoutMs` (10 с без приветствия — 4400, место свободно), `maxFrameBytes` (1 МиБ, 1009) — единый
39
+ для Hono и node. Чат расхода из `?chat=` — только свой (`service.assertOwnChat`), иначе `"voice"`.
40
+ `allowedLanguageHints` с регионом (`pt-BR`) разрешает язык `pt`. Относительный `relayUrl` снова работает.
41
+ - Язык каждой записи: `voice.start(listener, { chatId, language })`, `createVoiceInput({ languageHints })`; сервер
42
+ проверяет значение сам (`allowedLanguageHints`). `AgentChat` передаёт текущий чат и язык словаря.
43
+ - react: подпись `time_limit` — строка или функция от предела сервера («предел 3 минуты»); `onError(kind, details)`;
44
+ подписи `limit`, `forbidden`, `too_long` необязательные в своём словаре; `voiceErrorText`.
45
+
46
+ ## 0.54.0
47
+
48
+ ### Minor Changes
49
+
50
+ - fa90219: Битые аргументы инструментов: дописываются забытые закрывающие `]` и `}`. Модель иногда не закрывает массив или
51
+ объект (`{"facts": ["a", "b"}`, `{"fields": [{…}`). Теперь ядро дописывает недостающие скобки в правильном порядке,
52
+ но только когда JSON оборван на законченном значении. Обрыв внутри строки, ключа, числа или слова `true`, после
53
+ запятой или двоеточия, а также больше трёх недостающих скобок — не чинятся. Починенный вызов, как и раньше, проверяется
54
+ схемой инструмента. В строке журнала `[аргументы починены]` — `правка=дописаны скобки ×N`; в `ToolInputRepair` — поле
55
+ `missingClosers`.
56
+
57
+ ## 0.53.0
58
+
59
+ ### Minor Changes
60
+
61
+ - b6a7786: Сценарии: конец разговора без сбоев, «не сейчас», пояс по умолчанию, реплика сотрудника, тихое утро, касания с
62
+ карточками; расход по списку чатов.
63
+
64
+ - Конец разговора («срок вышел», «не завершил», прощание) больше не зависит от того, начался ли ход касания. Касания
65
+ сценария не режет потолок ядра «5 ходов сервера подряд»; у `deliverNotices` — свой потолок `maxServerTurnsInRow`
66
+ (число или `false`, по умолчанию 5, `MAX_SERVER_TURNS_IN_ROW`).
67
+ - Мягкий потолок сценария: 50 ходов сервера подряд без человека (`timers.maxServerTurnsInRow`), дальше касания ждут
68
+ его сообщения, конец разговора — в свой срок. Срок без причин писать не повторяется каждый час.
69
+ - `canWriteNow` → `false` — «не сейчас»: касание через час; `"never"` — молчать до сообщения человека; `{ retryAt: Date }`
70
+ — в свой момент (негодная дата — как `false`, прошлая — через минуту). Сроки конца разговора не снимаются.
71
+ - Человек писал до начала дня меньше 3 часов назад (`skipMorningIfHumanWithinMin`) — касания в начале дня ждут, не только
72
+ утреннее. Переход ноды по сроку и ответ на ночные сообщения не ждут.
73
+ - Сообщения после жёсткой рамки дня ставят срок на утро сами: ответ на накопленное приходит в `dayStart` при любых
74
+ касаниях. Их время — в данных чата (`flowHeldHuman`); для правила 3 часов, «не завершил» и перехода «человек молчит»
75
+ они — сообщения человека.
76
+ - `attach.deliver` получает `parts` — все части ответа для человека (карточка ролика, картинка, файл, виджет), `text`
77
+ прежний; `deliverNotices({ awaitAnswer }).answer.parts`; `deliverableParts` из ядра.
78
+ - `storage.sumUsageByChat({ chatIds, ownerId?, source?, since? })` — расход по списку чатов одним запросом (память,
79
+ SQLite, PostgreSQL; общие тесты хранилищ).
80
+ - `createAgentFlow({ defaultTimeZone })` — пояс, пока пояс человека неизвестен. Порядок: человек → страница →
81
+ `defaultTimeZone` → пояс службы → сервер. `prepareTurn` получает `pageTimeZone` — пояс, присланный страницей.
82
+ - `service.appendStaffMessage({ ownerId, chatId, text, staffName?, wakeAgent? })` — реплика живого сотрудника: модель
83
+ видит её словами сотрудника, `person_link_view` словами человека её не считает, проверки ответа агента её текст знают,
84
+ ход сама не начинает.
85
+ `flow.noteStaffReply` пишет тем же путём и принимает `staffName`. Экран: необязательный `StaffMessage` скина (договор 25),
86
+ без него — ответ агента с подписью `labels.notices.staffSaid`.
87
+ - `flow.tick()` в публичном типе, `factId` из пакета, перемотка до утра у чата, закреплённого через `adopt` без хода.
88
+
89
+ Как обновить приложение: снимите `it.fails` у теста «молчун все три дня»; ответ эксперта кладите
90
+ `appendStaffMessage` / `noteStaffReply` вместо сообщения собеседника и уберите обёртку `withoutNonPersonWords`; по желанию
91
+ `defaultTimeZone` и `StaffMessage` в своём скине; снимите `it.fails` у теста «касание, отложенное ночью до утра»;
92
+ доставляйте `message.parts` в своей доставке касаний; расход по чатам — `sumUsageByChat`.
93
+
94
+ ## 0.52.0
95
+
96
+ No changes in this release.
97
+
98
+ ## 0.51.0
99
+
100
+ ### Minor Changes
101
+
102
+ - 41b1726: Проверка ответа: **тексты приложения.** В `replyChecks` появилось `knownTexts: ({ ownerId, chatId }) => string[] | Promise<string[]>` — первое сообщение, прощание, названия роликов: слова и ссылки оттуда больше не вырезаются и не считаются чужими. Если `replyChecks` передан в `flow.withChat(…)`, сценарий сам добавляет свои тексты (общая часть, ноды, первые сообщения, прощание, рамка дня, попутные задачи). **Проверка текста вне хода:** `service.checkReply({ ownerId, chatId, text })` → `{ text, notes }` — те же проверки и тот же набор «виденного», что у хода. **Исправление:** служебные сообщения приложения (`app`, «напиши первым») и реплики сотрудника больше не делают ссылку законной — «виденное от человека» берётся только из сообщений человека (итоги фоновых задач остаются виденными). Если агент должен повторять ссылки из своих служебных сообщений, назовите их в `knownTexts`. Подробно — `docs/reply-check.md`.
103
+
104
+ ## 0.50.0
105
+
106
+ No changes in this release.
107
+
108
+ ## 0.49.0
109
+
110
+ ### Minor Changes
111
+
112
+ - 084cef7: **Ломает приложения (типы и своё хранилище сценариев):** у типа поля сценария `FlowFieldType` появилось значение `"boolean"`, у источника
113
+ расхода `UsageSource` — `"first_message"`, у `SteerResult.status` — `"held"`. Исчерпывающий `switch` по ним перестанет
114
+ собираться. Своё хранилище сценариев (`FlowStore`) должно реализовать новый `renewLease()` и третий аргумент `setDue`.
115
+
116
+ Сценарии для приложений: программа по дням (ночь, касания, рамки дня, «срок вышел»), своя служба чата и доставка с
117
+ исходами, пауза сотрудника, конец разговора с итогом, карточка и пояс человека в базе приложения, попутные задачи.
118
+ Проверка готового ответа: «чужие слова» и «выдуманные ссылки». Подробно — `docs/flows.md`, `docs/reply-check.md`,
119
+ раздел 0.49.0 в `CHANGES.md`.
120
+
121
+ - **flows**: формат сценария версии 2 (`schedule`, `nudges`, таймер `state_days`, поле `boolean`, `sideTasks`,
122
+ `afterEnd`; версия 1 читается сама); `attach({ portFor, deliver })`, `admit`, `pause`/`resume`/`noteStaffReply`,
123
+ `reportDelivery`, `onFinish`, `clientCard`, `people`, `settled`, `now`; причина перехода `reason`; перенос чата
124
+ через `loadFlow` (`stateId`, `startedAt`, `enteredAt`); заголовки судьи `judge.openrouter.headers`; встроенная задача
125
+ «узнать город»; `checkFlow(flow, catalog, { checks })` и `FlowAppChecks` из `/format`; в редакторе панели дней,
126
+ касаний, рамок и попутных задач, `catalog.fields`, кнопка «К началу следующего дня» (`advanceToNextDay`). Новые
127
+ таблицы `flow_people`, `flow_side_tasks` и колонки `flow_chats` создаются сами.
128
+ - **core**: `replyChecks` (список или `{ checks, web: "replace" | "hold" }`), `now`, `markUndelivered`, ответы
129
+ `beforeTurn` `{ hold: true }` и `{ reply: { text } }`, `deliverNotices({ timeZone, awaitAnswer, usageSource })`,
130
+ источник расхода `first_message`.
131
+ - **flows, надёжность**: обход таймеров до 5 ходов одновременно; копия продлевает аренду чата перед ходом и во время
132
+ него. В `FlowStore` новые `renewLease()` и `setDue(…, { unlessDelivery })`. Недоставленное касание (`failed`,
133
+ `window_closed`) не засчитывается — тот же шаг через свой срок от отказа; в `FlowRun` новое поле `touch`. «Окно
134
+ закрыто» снимает любое сообщение человека; «не завершил» считает тишину и от конца паузы сотрудника; пока ждёт
135
+ повтор доставки, срок не затирается и новых ходов нет.
136
+ - **server**: `createChatServer` передаёт службе `replyChecks` и `now`.
137
+ - **messengers**: `onAnswerDelivery` — исход доставки каждого ответа; закрытое окно ВК (950) —
138
+ `MessengerWindowClosedError` и исход `window_closed`; с `replyChecks` уходит только проверенный текст.
139
+ - **web-tools**: `createPersonLinkTool` — инструмент `person_link_view`, агент смотрит ссылку, которую прислал человек
140
+ (адрес из почты ссылкой не считается).
141
+ - **reply-check** (новый пакет): `foreignWords` и `unknownLinks`; ответ из одной выдуманной ссылки заменяется запасной
142
+ фразой `labels.linkOnlyReply` (`russianReplyCheckLabels`, `englishReplyCheckLabels`, `ReplyCheckLabels`).
143
+ - **react**: договор скина версии 24 — необязательное `undelivered` у ответа агента и надпись `answerUndelivered`
144
+ («Не доставлено»). Скин версии 23 работает без правок.
145
+
146
+ ### Как обновить приложение
147
+
148
+ 1. Поднимите все пакеты `@ykurilov/agent-chat-*` до этой версии вместе.
149
+ 2. Найдите в коде приложения исчерпывающие `switch` и словари по этим типам и добавьте ветки:
150
+ - по `FlowFieldType` (поля карточки сценария) — `"boolean"`, «да/нет», значения `true`/`false`;
151
+ - по `UsageSource` (отчёты расходов) — `"first_message"`, «агент написал первым»;
152
+ - по `SteerResult.status` — `"held"`, «сообщение записано, хода нет».
153
+ 3. Своё хранилище сценариев (свой `FlowStore`) — добавьте `renewLease({ chatId, owner, now, leaseMs }): Promise<boolean>`
154
+ (продлить аренду до `now + leaseMs`; `false` — чат уже не ваш) и третий аргумент `setDue(chatId, due, { unlessDelivery })`
155
+ (при `unlessDelivery` не писать срок, пока чат ждёт повтора доставки). Готовые хранилища SDK это уже умеют.
156
+ 4. Сценарии сохраняются версией 2, а 0.48 её не откроет. Перед откатом на 0.48 держите копии сценариев версии 1.
157
+ 5. Новое по желанию: каналы SDK и сценарий — `createMessengers({ onAnswerDelivery: (report) => flow.reportDelivery(report) })`;
158
+ проверка ответа — `pnpm add @ykurilov/agent-chat-reply-check` и `replyChecks` в `createChatServer`.
159
+ 6. Проверка: `pnpm typecheck` проходит, чат отвечает, сценарий версии 1 открывается в редакторе.
160
+
161
+ ## 0.48.0
162
+
163
+ ### Minor Changes
164
+
165
+ - fa10d5a: Сценарии поведения агента: разговор идёт по шагам с условиями выхода, судьёй, таймерами «написать первым» и карточкой
166
+ клиента, плюс редактор сценария с тестовым чатом. Подробно — `docs/flows.md`.
167
+
168
+ - **flows** (новый пакет): `createAgentFlow`, формат и проверка сценария, судья decision-1 с запасной моделью, таймеры,
169
+ карточка клиента, перевод чатов на новую версию, хранилища SQLite и PostgreSQL, `FlowEditor`, `FlowTestChat`, `ClientCardPanel`.
170
+ - **core**: новые точки для расширений — `agent.prepareTurn` (контекст хода с `resumed`, `audience`, `staff?`),
171
+ служебное сообщение `app`, готовый ответ `deliverNotices({ reply })`, `askModel` и `recordUsage`, источник расхода
172
+ `judge`, `ChatService.assertWithinLimit(ownerId, chatId)`; `chatInstructions(chat, turn?)` — второй аргумент с
173
+ `audience` (при сжатии истории его нет).
174
+ - **server**: `chatInstructions` в настройках принимает тот же второй аргумент `turn`.
175
+ - **messengers**: ответ сервера без модели (точный текст по таймеру сценария) теперь доходит до мессенджера.
176
+ - **skin-tandem**: новые входы `/flows` (`tandemFlowEditorSkin`) и `/client-card` (`tandemClientCardSkin`).
177
+
178
+ ### Как обновить приложение
179
+
180
+ Сценарии не нужны — обновление ничего не требует. Нужны — шаги в разделе «Как обновить приложение» в `CHANGES.md`:
181
+ `pnpm add @ykurilov/agent-chat-flows`, `createAgentFlow` + `flow.withChat(…)`, `flow.attach(service)`, `flow.start()`,
182
+ `OPENROUTER_API_KEY` на сервере. Тестовому чату нужен `readRun(chatId)` в `FlowTestChatConnection`; его адреса
183
+ закройте правами редактора. `firstMessages: false` теперь значит «без тихих часов и потолка», запрет —
184
+ `canWriteNow: () => false`. Проверка: `pnpm typecheck`, новый чат отвечает по первому состоянию сценария.
185
+
186
+ ## 0.47.0
187
+
188
+ ### Minor Changes
189
+
190
+ - 5a59c33: Уровень «Максимум» больше не съедает ответ на рассуждения. В профиле модели новое поле `maxOutputTokens` (потолок вывода): уходит в каждом запросе как `max_tokens`, если приложение не задало `agent.maxOutputTokens`; у Claude Haiku 5.5 — 128 000. Ответу остаётся пятая часть предела и не меньше 16 тысяч. Новое поле `reasoning.effortShareOfOutput`: у моделей, где сила рассуждений — доля предела (Claude через OpenRouter, `xhigh` — 95 %), уровень уходит самой глубокой силой с запасом на ответ — у Haiku 5.5 «Максимум» теперь `high`. Новое поле `reasoning.tokenBudget`: модель, которая держит бюджет рассуждений, получает его числом (`reasoning.max_tokens` у OpenRouter). Как обновить своё приложение: в своей копии профиля `claude-haiku-5.5` добавьте `maxOutputTokens: 128_000` и `reasoning.effortShareOfOutput: true`.
191
+
192
+ ## 0.46.2
193
+
194
+ ### Patch Changes
195
+
196
+ - 3d0d2f2: Claude через OpenRouter больше не падает с ошибкой «A maximum of 4 blocks with cache_control may be provided. Found 5» после параллельных вызовов инструментов: метка кэша у результатов инструментов — одна, на последнем результате, а у моделей `anthropic/…` в теле запроса не бывает больше 4 меток. Часовые цены записи кэша (`cacheWrite1hPerMillion`) у GPT 6 Luna, DeepSeek 4.1 Flash и GLM 5.3 Flash через OpenRouter.
197
+
198
+ ## 0.46.1
199
+
200
+ No changes in this release.
201
+
202
+ ## 0.46.0
203
+
204
+ ### Minor Changes
205
+
206
+ - 56d7528: Наблюдения «Умных форм», без ломающих изменений.
207
+
208
+ - **Начатый вызов в базе до выполнения.** Вызов инструмента пишется в хранилище сразу, как модель его прислала, а
209
+ инструмент ждёт этой записи (не дольше 5 с). Упади сервер посреди инструмента — восстановленный ответ показывает
210
+ вызов прерванным, «Повторить» продолжает с него.
211
+ - **Последний шаг без действий.** На шаге без инструментов (последний шаг хода и помощника, итог по потолку хода)
212
+ вызов вопреки запрету не выполняется: у него закрывающий результат-отказ с постоянным текстом, подтверждение не
213
+ спрашивается.
214
+ - **`afterTurn` видит `stepLimit: true`**, когда ход упёрся в `agent.maxSteps`; в журнале строка `[предел шагов]`, в
215
+ ответе часть `data-step-limit` (модель её не видит). Человеку на экране ничего не показывается.
216
+ - **«Повторить» передаёт `previousAnswerId` и `abandonedAnswerIds`** в `beforeTurn` и `turnNotStarted`: оборванный
217
+ ответ, который заменит новый ответ-сосед.
218
+ - **React: «ответа уже нет» — лента сразу перечитывает историю.** Виджет, подтверждение и браузерный вызов, на которые
219
+ сервер ответил «не ждёт», перечитывают историю; сорвавшееся под нагрузкой чтение повторяется, лента не застывает.
220
+ - **Тандем: поле ввода не пересоздаётся**, когда приходят меню моделей, вложения, голос и другие поздние части
221
+ панели. Текст, набранный до загрузки меню, и фокус остаются.
222
+
223
+ ### Как обновить приложение
224
+
225
+ Обновление само по себе ничего не требует. Обходы, которые теперь можно убрать:
226
+
227
+ 1. Тесты, которые перед вводом текста ждут кнопку «Модель» (например, `tests/support/test-chat-ready.ts`), — ждать её
228
+ больше не нужно: текст, набранный раньше меню, не пропадает.
229
+ 2. Тесты восстановления, которые перед остановкой сервера ждут `status: complete` в базе, могут обрывать сервер
230
+ посреди инструмента: вызов уже в базе и после восстановления виден прерванным.
231
+ 3. Свои проверки «последний ли это шаг» внутри инструментов с последствиями (бронь, оплата) — SDK сам не выполняет
232
+ вызов на последнем шаге.
233
+ 4. Свой подсчёт шагов в `afterTurn`, чтобы понять, что ход упёрся в предел, — заменить на `turn.stepLimit`.
234
+ 5. Своё вычисление прежнего ответа для «Повторить» — брать `previousAnswerId` из `beforeTurn` / `turnNotStarted`.
235
+ - a1933fd: Битые аргументы инструментов чинятся без повтора: лишняя закрывающая `}` или `]` (в конце JSON или после элемента
236
+ списка, не больше двух) убирается, починенный вызов SDK заново проверяет схемой инструмента. Каждая починка — строка
237
+ `[аргументы починены]` в журнале сервера без содержимого аргументов.
238
+
239
+ ### Patch Changes
240
+
241
+ - 52cc67d: Заметка хода (место на экране, день, блоки `contextBlocks`) — у последнего входа хода. После ответа на виджет или
242
+ подтверждение свежая заметка встаёт сразу за ответом человека, последним сообщением запроса; раньше она оставалась в
243
+ давнем вопросе, и новое место модель не видела. Ничего не поменялось — заметки нет. Начало запроса байт в байт прежнее,
244
+ кэш модели цел. Каждая заметка продолжения — ещё одна часть `data-frozen-context` в ответе: читаете ответы из хранилища
245
+ сами — пропускайте все такие части.
246
+
247
+ ## 0.45.0
248
+
249
+ No changes in this release.
250
+
251
+ ## 0.44.0
252
+
253
+ ### Minor Changes
254
+
255
+ - 6a2b5d9: Живые демонстрации: блок ```html-demo в ответе запускается в изолированной рамке (`sandbox="allow-scripts"`, строгий CSP,
256
+ наружу только высота). `agent.liveDemos` на сервере — одна постоянная строка инструкции; `liveDemos` у `AgentChat`,
257
+ `ChatWorkspace` и `AgentPanel`. Договор скина v22: необязательные `AgentText.renderDemo` и `ChatSkin.demoColors`, Тандем
258
+ умеет. Мессенджеры вместо блока пишут `liveDemoOnSite`, если у службы чата `liveDemos` (новое необязательное поле `ChatService.liveDemos`).
259
+ Код живёт в рамке внутри рамки: уйти на чужой адрес он не может, WebRTC убран. Выключатель: в чате с чужим текстом (веб-инструменты, файл человека, инструмент с `metadata: { externalContent: true }`)
260
+ демонстрации не запускаются — признак `ChatHistory.externalContent` и кусок потока `data-external-content`. Вложенные рамки
261
+ и `<link rel=preconnect>` в документе демонстрации запрещены. Всё необязательное, ломающих изменений нет. Как обновить
262
+ приложение — в CHANGES.md, раздел 0.44.0.
263
+
264
+ ## 0.43.0
265
+
266
+ ### Minor Changes
267
+
268
+ - 763f4c5: Claude Haiku 5.5 (`claude-haiku-5.5`) в `standardCatalog` через OpenRouter, с кэшем на час. Срок кэша теперь задаётся по
269
+ модели: `cacheTtl` в профиле, `cacheTtlByModel` и `cacheTtl` в `createOpenRouterProvider` (настройка провайдера главнее
270
+ профиля). У остальных моделей запрос прежний. Цена часовой записи — новое поле `cacheWrite1hPerMillion`; каталог отмечает в
271
+ цене `cacheWriteTtl: "1h"`, и расход считается по сроку, с которым модель пишет кэш. `requestOptions` провайдера получает
272
+ `profile`; новый необязательный метод `cacheTtlFor(profile)`.
273
+
274
+ ## 0.42.0
275
+
276
+ ### Minor Changes
277
+
278
+ - a385276: Публичный чат больше не выдаёт посетителю служебное: модель, провайдера, расход, текст сводки сжатия и закрытые
279
+ ключи состояния. Ничего не ломает: по умолчанию всё как раньше.
280
+
281
+ - Новое право `modelDetails` в `chatCapabilities` (по умолчанию `true`). `false` убирает из истории и потока ответа
282
+ модель, провайдера и уровень рассуждений чата, `ownerId`, `metadata.modelId`/`providerId`/`usage` у сообщений,
283
+ `providerMetadata`/`callProviderMetadata`/`resultProviderMetadata` у частей и кусков, провайдера у отметки
284
+ `data-step-reset`, текст сводок сжатия (`summary: ""`, `summaryTokens: 0` в истории и `GET …/compactions`) и
285
+ выключает выбор модели (`modelChoice`). То же при переподключении и смене ветки. Хранилище, модель, хуки и лимиты
286
+ получают всё.
287
+ - Опция сервера и службы `modelMenu: ({ userId }) => boolean` закрывает `GET …/models` (403 `action_forbidden`,
288
+ `action: "modelDetails"`): меню чата не знает, право его не закрывает.
289
+ - Тандем рисует разделитель сжатия без текста сводки строкой без кнопки.
290
+ - Опция `privateStateKeys: ["booking"]` — закрытые верхние ключи состояния чата: не уходят ни в историю, ни в поток,
291
+ ни в `state-changed` наблюдения (`useChatState` их не видит). `getChatState`, `onStateChanged`, события и модель
292
+ видят их как обычно. Имя без точки и не на `$`, иначе `TypeError` при создании. Закрытое значение не возвращайте в
293
+ выходе инструмента (его прячет лишь `toolDetails: false`) и в тексте ответа (не прячется никак).
294
+ - Экран запрашивает меню моделей после истории и только если оно нужно: разрешён выбор модели (или вложениям старого
295
+ сервера). История всегда несёт `modelAcceptsImages` — видит ли модель чата картинки.
296
+
297
+ ### Как обновить приложение
298
+
299
+ Публичному чату (посетитель без входа) в `server/chat.ts` или где создаётся обработчик. Посетитель — человек со своим
300
+ `userId` от `resolveUser` (например, `visitor:<id сессии из cookie>`); `isVisitor = (userId) => userId.startsWith("visitor:")`.
301
+ `modelDetails: false` прячет и модель, и текст сводок сжатия; `toolDetails: false` сводку не трогает.
302
+
303
+ ```ts
304
+ createChatHandler({
305
+ // …
306
+ chatCapabilities: ({ userId }) => (isVisitor(userId) ? { modelDetails: false, toolDetails: false, reasoning: false /* , … */ } : {}),
307
+ modelMenu: ({ userId }) => !isVisitor(userId),
308
+ privateStateKeys: ["booking"], // верхние ключи состояния со служебными id
309
+ });
310
+ ```
311
+
312
+ Свой скин: `CompactionDivider` может получить `summary: ""` (при `modelDetails: false`) — раскрывать нечего, покажите
313
+ только строку.
314
+
315
+ ## 0.41.0
316
+
317
+ ### Patch Changes
318
+
319
+ - 9472fcb: Поправка на лету и английский интерфейс.
320
+
321
+ - `onTurnFinished` в `AgentChat` приходит и для ответа, закрытого ради поправки, — перед ответом на поправку. Раньше
322
+ событие получал только последний ответ, и действия под первым (например, «Вернуть эту версию») не появлялись.
323
+ Событие есть, даже если продолжение по поправке не стартовало (лимит расходов, чужие ходы, чат закрыт).
324
+ **Внимание:** при поправке на ходу `onTurnFinished` теперь приходит на каждый ответ — обработчик, который сохраняет
325
+ снимок или считает ходы, увидит 2 события и больше.
326
+ - Продолжение после поправки получает `place` самой свежей поправки, где он есть, иначе начала хода: служебная заметка
327
+ приложения уже новая, а поправка только с `tabId` место не стирает. Вкладка и пояс — от последней поправки: браузерные
328
+ инструменты продолжения идут на её вкладку. Кэш цел — блок места ложится в хвост поправки.
329
+ - `englishLabels` — полный английский словарь чата. `englishAttachmentsLabels`, `englishWebToolTitles`,
330
+ `englishHistorySearchTitles`, `englishMemoryToolTitles` (два последних — и из корня пакета); `withWebToolTitles` и
331
+ соседи берут словарь названий вторым аргументом (по умолчанию русский).
332
+
333
+ Как обновить: поднять версию. Английский интерфейс — `<ChatLabelsProvider labels={englishLabels}>` и
334
+ `createAttachmentsClient(englishAttachmentsLabels)` вместо своих словарей.
335
+
336
+ ## 0.40.0
337
+
338
+ ### Minor Changes
339
+
340
+ - 0e7aab3: Наблюдения «Умных форм», без ломающих изменений:
341
+
342
+ - `useChatState(chatId)` — состояние чата вне `AgentChat` (панель рядом с чатом): те же значения и живые обновления,
343
+ без своих запросов.
344
+ - `onModelChosen` — человек выбрал модель в меню: колбэк `AgentChat` и хук службы (`createChatService`, `createChatServer`).
345
+ - `{ by: "app" }` у `service.deleteChat` и `service.setModel` — сервер приложения действует без прав экрана; адреса HTTP
346
+ проверяют права, как раньше.
347
+ - «Переделать» у приветствия скрыто, а сервер такой запрос отклоняет (`InvalidMessageError`).
348
+ - `recoverInterruptedAnswers({ soleServer: true })` — у единственной копии сервера оборванные ответы прерываются сразу,
349
+ а не через 15–20 с. Только при старте процесса; не с несколькими копиями на одной базе и не в dev-режиме Next.js.
350
+
351
+ Как обновить: поднять версию, правки не обязательны. Обходные пути можно заменить — см. CHANGES.md «0.40.0».
352
+
353
+ ## 0.39.1
354
+
355
+ No changes in this release.
356
+
357
+ ## 0.39.0
358
+
359
+ ### Minor Changes
360
+
361
+ - 3b90429: Виджеты: `onStateChanged` с причиной `widgetAnswer` после записей `checkAnswer`; `ctx.emitAppEvent` в `checkAnswer`
362
+ (событие вместе с ответом виджета, на странице — живым один раз); `chatId` и `toolCallId` в `CustomWidgetProps`;
363
+ `agent.widgets.enabledFor` — набор виджетов по чату, выключенных модель не видит, их прошлые вызовы идут модели текстом.
364
+ Всё необязательное, ломающих изменений нет. Как обновить приложение — в CHANGES.md, раздел 0.39.0.
365
+
366
+ ## 0.38.0
367
+
368
+ ### Minor Changes
369
+
370
+ - 1cae504: Четыре добавки для форм и похожих приложений, всё необязательное, старый код работает как раньше.
371
+
372
+ - `beforeTurn` и `turnNotStarted` у «Переделать» (`regenerate`) и правки вопроса (`edit`) получают `abandonedAnswerIds` —
373
+ ответы агента, которые уйдут с ветки, — и `previousAnswerId` — заменяемый ответ. Откатывайте по ним побочные дела
374
+ брошенных ответов; пришёл `turnNotStarted` — ветка прежняя, верните откат по тем же полям. Тип `AbandonedAnswers`.
375
+ - `agent.contextBlocks` получает `state` — состояние ветки на начало хода (копия). Звать `getChatState` внутри функции
376
+ больше не нужно.
377
+ - `service.getChatData(userId, chatId)` — данные чата копией, без записи и события. Вместо пустого `updateChatData(…, {})`.
378
+ - `endTurn(output, { text? })` — результат инструмента, после которого модель следующего шага не делает. Готовый `text`
379
+ идёт в ответ обычной текстовой частью без запроса к провайдеру и без расхода; `output` модель видит как обычно. Тип
380
+ `EndTurnOptions`. Свои реализации интерфейса `ChatService` (заглушки в тестах) дополните методом `getChatData`.
381
+ - 4065a4a: Картинки для моделей без зрения. Зрение модели теперь решается для пары «модель + провайдер»: в профиле
382
+ `acceptsImagesByProvider: { together: false }` главнее `acceptsImages`, решение даёт `modelAcceptsImages(profile, providerId)`
383
+ из ядра, по нему меню моделей и вложения. В стандартном каталоге DeepSeek 4.1 Flash и GLM 5.3 Flash через Together
384
+ отмечены без зрения (у Together они только текстовые) — картинка туда больше не уходит, модель получает пометку.
385
+
386
+ Во вложениях:
387
+
388
+ - `createAttachments({ imagesForBlindModels: { toolName, marker?, always? } })` — модели без зрения вместо картинки идёт
389
+ постоянная плашка `[Картинка «shot.png», вложение <id>. Посмотреть: <toolName>.]`; к инструкции вложений дописывается
390
+ строка про неё. Без настройки — прежняя пометка «картинка не показана».
391
+ - `attachments.readChatImage({ chatId, attachmentId })` — картинка только этого чата для инструмента приложения
392
+ (`chatId` — из `AgentToolsModel.chatId`); чужое, не картинка, неверный номер — `undefined`.
393
+ - `createAttachments({ acceptedMediaTypes: ["image/png", "image/jpeg", "image/webp"] })` — файл другого типа отклоняется
394
+ `unsupported_type` до разбора и записи; поле ввода получает список из настроек и отказывает до загрузки.
395
+ Решает тип, с которым файл пришёл (поле записи `sourceMediaType`): ужатый в JPEG PNG при `["image/png"]` проходит.
396
+ Импорт чата вместо картинки неподходящего типа пишет «[картинка: имя]».
397
+ - `isAttachmentId` — из публичного входа.
398
+ - Снимок страницы (`page_screenshot`) тоже решает по паре «модель + провайдер».
399
+
400
+ Ничего не ломает: приложениям без этих настроек делать ничего не нужно. Как убрать свой обход — в CHANGES.md,
401
+ раздел 0.38.0 «Как обновить приложение».
402
+
403
+ ## 0.37.0
404
+
405
+ ### Minor Changes
406
+
407
+ - 7f49d12: Провайдера можно скрыть из меню выбора модели: поле `hiddenFromMenu: true` в фабриках `createOpenRouterProvider`,
408
+ `createTogetherProvider`, `createFireworksProvider`, `createDeepSeekProvider` (и в описании провайдера, собранного
409
+ вручную). Скрытого и его моделей нет в `GET {base}/models` и в выборе чата; `PUT /:id/model` на него отказывает, как на
410
+ неизвестного. Как `defaultChoice` и запасной в `providerFallback` он работает как обычный провайдер. Одно правило: скрытый
411
+ выбор в чате равен выбору по умолчанию. Чат с сохранённым скрытым провайдером идёт на выборе по умолчанию (запись в базе
412
+ не меняется); `importChat` с моделью скрытого провайдера не падает, а заводит чат без своего выбора; запомненный человеком
413
+ скрытый выбор не попадает в новые чаты (`openSubjectChat`). Если сам `defaultChoice` у скрытого, `GET /models` отдаёт его
414
+ как есть и добавляет `defaultChoice.details` (названия, картинки, уровни); `useModelChoice` показывает его текущей моделью,
415
+ выбрать вручную нельзя. Запасной по умолчанию и переход при молчании могут идти на скрытого. Подключение через прокси (`connectProviders`) флаг не теряет. Не boolean — ошибка при сборке каталога.
416
+
417
+ ```ts
418
+ extendCatalog(standardCatalog, {
419
+ addProviders: [createOpenRouterProvider({ id: "or-fastest", sort: "throughput", hiddenFromMenu: true })],
420
+ });
421
+ ```
422
+
423
+ Базовый `openrouter` прятать подменой `apiKeyEnv` больше не нужно: `createOpenRouterProvider({ hiddenFromMenu: true })`.
424
+ - ea78982: **Ломает приложения:** изменения памяти больше не доходят в уже идущий чат на каждом ходе — они придут, когда память прочитается заново (новый чат, сжатие истории и случаи ниже).
425
+
426
+ Память теперь ведёт себя как файл правил в Claude Code: читается один раз на чат. В идущем чате ядро не зовёт
427
+ `snapshot()` и `blocks()` памяти и не досылает модели ни новых, ни изменённых записей, ни строки «Больше не действует»
428
+ про удалённые. Запись, сделанную самим агентом, он видит в переписке как вызов инструмента.
429
+
430
+ Память читается заново: в новом чате; после сжатия истории (целиком, новые факты не теряются); при смене начала
431
+ истории — правке первого сообщения или «Переделать» первого ответа; в первом личном ходе после группового (групповой
432
+ ход память не читает); в ходе после сбоя чтения памяти. Чат из прошлой версии перечитает память один раз, при первом
433
+ ходе после обновления. Дата, место на экране и блоки `agent.contextBlocks` досылаются припиской,
434
+ как раньше. В служебной части ответа `data-frozen-context` новое необязательное поле `memoryFrom`.
435
+
436
+ ### Как обновить приложение
437
+
438
+ 1. Код менять не нужно: типы и настройки прежние.
439
+ 2. Если приложение рассчитывало, что правка памяти на экране памяти или запись из другого чата сразу дойдёт до модели
440
+ в открытом чате, — теперь она дойдёт в новом чате. Нужна в идущем чате — дайте человеку начать новый чат или сожмите
441
+ историю (`service.compactNow(ownerId, chatId)`).
442
+ 3. Свои тесты, которые ждут приписку памяти во втором ходе того же чата, поправьте: приписки больше нет; проверяйте
443
+ новый чат.
444
+ 4. Своя обёртка памяти (`LongTermMemory`) с подсчётом вызовов `snapshot()`/`blocks()` на каждый ход: теперь вызов один
445
+ на чат и после сжатия.
446
+ 5. Проверка: `pnpm typecheck` и тесты проходят; в новом чате модель видит свежую память.
447
+
448
+ ## 0.36.0
449
+
450
+ ### Minor Changes
451
+
452
+ - 3487fb4: Публичному посетителю можно не показывать кухню агента. Новые права в `chatCapabilities`: `reasoning` (мысли модели) и
453
+ `toolDetails` (служебные инструменты). По умолчанию оба `true` — всё как раньше.
454
+
455
+ - При запрете мысли и вызовы служебных инструментов (вход, выход, запрос подтверждения) не уходят в браузер: ни в историю
456
+ (`GET`, `getHistory`, `peekHistory`, `selectBranch`), ни в поток ответа, ни в повтор кусков после переподключения
457
+ (`GET …/stream`). Из метаданных ответа пропадает `reasoningDurationsMs`, у текста и вызовов — детали рассуждений
458
+ OpenRouter (`providerMetadata.openrouter.reasoning_details`).
459
+ - Модель, хранилище, `afterTurn`, сжатие и поиск по истории получают ответ целиком.
460
+ - Виджеты (`agent.widgets`) и браузерные инструменты видны всегда. Свой инструмент с карточкой на экране пометьте в его
461
+ описании — `metadata: { showOnScreen: true }` (поле AI SDK, модели не уходит). Метка записывается в каждый вызов
462
+ (`toolMetadata`), поэтому работает и для набора инструментов функцией от модели. Вызовы, записанные до пометки, метки
463
+ не имеют и при запрете скрыты.
464
+
465
+ ```ts
466
+ const showCard = tool({ description: "Карточка тарифа", inputSchema, metadata: { showOnScreen: true }, execute });
467
+ createChatServer({
468
+ agent: { instructions, tools: { show_card: showCard, lookup } },
469
+ chatCapabilities: ({ userId }) => (isVisitor(userId) ? { reasoning: false, toolDetails: false } : {}),
470
+ });
471
+ ```
472
+
473
+ - Права считаются на того, кто открыл поток, в момент открытия.
474
+ - Вызов, который спросил подтверждения (`needsApproval`), виден сам, без пометки: иначе человек не увидит вопроса и ход
475
+ зависнет. Вызов того же инструмента без вопроса скрыт.
476
+ - Свои тесты, которые сравнивают `capabilities` истории целиком, получат два новых поля `reasoning` и `toolDetails`.
477
+
478
+ Ломает типы: тип ChatCapabilities получил новые обязательные поля, литералы надо дополнить.
479
+ - 8c43b86: `importChat` переносит состояние чата и закрытие. Новые поля `chat.state` (JSON-объект, как его отдаёт
480
+ `getChatState().values`) и `chat.closed` (`{ code, message }`, как у `closeChat`). Сразу после импорта `getChatState`,
481
+ `model.state` и экран (`data-app-state`) видят состояние, закрытый чат закрыт: отправка — `ChatClosedError` (409
482
+ `chat_closed`), правка открывает новую ветку. Состояние пишется служебным сообщением перед перенесённой
483
+ историей: его видят все ветки («Переделать», правка любого сообщения, даже первого). История для браузера этого сообщения
484
+ не показывает, его записи приходят в начале первого сообщения; модель его не получает. Закрытие — частью последнего
485
+ сообщения: правка открывает новую ветку. Вызовы инструментов не переносятся.
486
+
487
+ ```ts
488
+ await service.importChat({
489
+ ownerId,
490
+ chat: { state: { fields: { email: "a@b.ru" }, step: 3 }, closed: { code: "form_completed", message: "Заявка отправлена." } },
491
+ messages,
492
+ });
493
+ ```
494
+
495
+ Ошибки до записи: не объект, верхний ключ на `$` (служебный; закрытие — только `chat.closed`), не простой JSON —
496
+ `TypeError`; верхний ключ больше 16 КБ или всё состояние больше 256 КБ — `AppStateTooLargeError`; неверный код
497
+ закрытия — `TypeError`. Своё хранилище менять не нужно.
498
+ - 6f42418: Свой срок молчания модели у провайдера. Фабрики `createOpenRouterProvider`, `createTogetherProvider`,
499
+ `createFireworksProvider`, `createDeepSeekProvider` (и любое `ProviderConnection`) принимают поле
500
+ `modelSilenceTimeout` в том же виде, что `agent.modelSilenceTimeout`. Заданные сроки перекрывают общие, пропущенные
501
+ берутся из общих, `false` — без сроков. Срок действует на каждую попытку у этого провайдера, в том числе после перехода
502
+ на него запасным (`providerFallback`). Подключение через прокси (`connectProviders`) срок не теряет. Запрос к модели не
503
+ меняется.
504
+
505
+ ```ts
506
+ extendCatalog(standardCatalog, {
507
+ addProviders: [
508
+ createOpenRouterProvider({ id: "or-together", only: ["together"], modelSilenceTimeout: { firstChunkMs: 3_000 } }),
509
+ createOpenRouterProvider({ modelSilenceTimeout: { firstChunkMs: 30_000 } }),
510
+ ],
511
+ });
512
+ ```
513
+
514
+ Неверный срок — ошибка при сборке каталога, например «OpenRouter: modelSilenceTimeout.firstChunkMs: ждём число мс…».
515
+ Провайдер, собранный вручную и добавленный через `extendCatalog`, проверяется так же.
516
+ - 5f13c74: Поправку к идущему ответу можно запретить правом `steer` в `chatCapabilities`. Раньше `POST …/steer` принимался, даже
517
+ если экран прятал поправку (`steering={false}`). Теперь при `steer: false` сервер отвечает 403
518
+ `{ error: { kind: "action_forbidden", action: "steer", … } }` до любой записи, `service.steer` бросает
519
+ `ActionForbiddenError`, а `<AgentChat>` не предлагает поправку и ждёт конца ответа. По умолчанию право `true`.
520
+
521
+ ```ts
522
+ chatCapabilities: ({ userId }) => (isVisitor(userId) ? { steer: false, edit: false, regenerate: false, branches: false } : {}),
523
+ ```
524
+
525
+ Свои тесты, которые сравнивают `capabilities` истории целиком, получат новое поле `steer`.
526
+
527
+ Ломает типы: тип ChatCapabilities получил новые обязательные поля, литералы надо дополнить.
528
+
529
+ ## 0.35.0
530
+
531
+ ### Minor Changes
532
+
533
+ - 2382026: **Ломает приложения:** память, сегодняшняя дата и место на экране больше не переезжают в последний вопрос на каждом
534
+ ходе. Они вписываются в вопрос того хода, где их прочитали, и дальше не меняются; набор хода хранится служебной частью
535
+ ответа `data-frozen-context`.
536
+
537
+ Что изменилось. Раньше блок памяти, дата и место стояли в конце последнего вопроса и на следующем ходе уезжали из него.
538
+ Из-за этого всё после старого вопроса (рассуждения, вызовы инструментов, их результаты) каждый ход читалось заново, мимо
539
+ кэша модели. Теперь блоки застывают там, где их показали, как в Claude Code. Следующий ход побайтно начинается с
540
+ последнего шага прошлого и читает всю переписку из кэша.
541
+
542
+ - **Изменения — только припиской.** В новый вопрос идут только изменившиеся блоки целиком и одна строка
543
+ `[Больше не действует: «ключ»]` про пропавшие. Ничего не менялось — вопрос без приписки.
544
+ - **Удалённая запись памяти** остаётся в старом вопросе, а в новом модель видит, что она больше не действует.
545
+ - **Запись, не влезшая в лимит памяти** (`maxBlockChars`), не считается удалённой. Модель, которая её уже видела,
546
+ получает строку `[Не показано ради места, но по-прежнему действует: «ключ»]` и находит её поиском. Так может любой
547
+ источник: элемент `{ key, hidden: true }` в списке блоков значит «блок есть, но не показан». Новый тип
548
+ `ContextBlockEntry = ContextBlock | HiddenContextBlock`.
549
+ - **Продолжение хода** (после подтверждения инструмента, автопродолжение, запасной провайдер, «Повторить») берёт блоки
550
+ из ответа и память заново не читает. Инструменты при этом получают свежее место (`model.place`), а модель видит
551
+ застывшее.
552
+ - **Память по записям.** `createLongTermMemory` отдаёт память блоками: шапка `<agent_memory>` (ключ `memory`) и по блоку
553
+ на запись (`memory:<id>`). Правка одной записи стоит одной строки, а не всего блока. Своя память может делать так же
554
+ через необязательный `LongTermMemory.blocks?({ userId, chatId })`; без него — один блок из `snapshot()`, как раньше.
555
+ Обёртка памяти в песочнице пробрасывает `blocks`. Счётчик «Показано агенту: N из M» считает записи, которые уходят
556
+ модели блоками.
557
+ - **Блоки приложения — `agent.contextBlocks`** (новое, по желанию). Приложение отдаёт список `{ key, text, private? }`:
558
+ скиллы, роль, правила. Он вписывается в вопрос после памяти и даты по тем же правилам:
559
+
560
+ ```ts
561
+ agent: {
562
+ instructions,
563
+ contextBlocks: async ({ chatId, ownerId, chat, audience, abortSignal }) => [
564
+ { key: "skills", text: `Скиллы агента: ${await skillsOf(ownerId, { signal: abortSignal })}` },
565
+ ],
566
+ }
567
+ ```
568
+
569
+ - Вход: `chatId`, `ownerId`, `messageId`, `chat.data`, `subject`, `channel`, `audience`, `staff`, `modelId`,
570
+ `providerId`, `abortSignal`. Данные и объект чата — копии.
571
+ - `private: true` — блок не показывается в групповом ходе (`audience: "group"`).
572
+ - Ключи `memory`, `memory:*`, `today`, `screen_place` заняты ядром. Такой блок, повтор ключа и пустой текст
573
+ отбрасываются, в `logError` строка `[блоки контекста] … отброшен`.
574
+ - `agent.contextBlocksTimeoutMs` — срок функции, по умолчанию 5000 мс; 0 или `Infinity` — без срока, иначе от 1000.
575
+ Не ответила или бросила — блоки приложения берутся из прошлого ответа чата, в `logError` строка
576
+ `[блоки контекста] … не ответил за N мс`. «Стоп» отменяет функцию сразу, без строки.
577
+ - Новые типы `ContextBlock`, `ContextBlocksInput`, `ContextBlocksSource`, `HiddenContextBlock`, `ContextBlockEntry`.
578
+ - **Кэш шагов у моделей с явными метками** (Claude, Gemini через OpenRouter). Со второго шага хода метка кэша стоит на
579
+ конце шага, до снимка `stepContext` и служебных пометок. Меток в запросе не больше трёх: инструкция, последнее
580
+ сообщение истории, конец шага. У провайдеров с автоматическим кэшем (DeepSeek, Fireworks, Together, GPT напрямую)
581
+ меток нет и запросы без лишних байтов. У Gemini через OpenRouter метка на самом конце запроса не работает (OpenRouter
582
+ её пропускает), поэтому у моделей `google/` она стоит на предпоследнем сообщении: конце прошлого хода или вызове
583
+ инструмента. У Claude через OpenRouter шаги с инструментами теперь пишутся в кэш (запись
584
+ дороже входа на 25%, при `cacheTtl: "1h"` — вдвое), а следующий ход читает их по цене чтения.
585
+ - **Журнал промахов кэша** (`[промах кэша]`) сравнивает первый шаг хода с последним шагом прошлого хода, без его
586
+ концовки (снимок, пометка попытки, пометка «подведи итог»).
587
+ - **Служебная часть `data-frozen-context`** не уходит в поток, историю (`getHistory`), `afterTurn` (`TurnResult.parts`) и
588
+ поиск по истории. Константа — `FROZEN_CONTEXT_PART_TYPE`. Миграций базы нет. Старый чат: первый ход после обновления
589
+ кладёт полный набор блоков в свой вопрос, прошлые вопросы остаются как были.
590
+
591
+ ### Как обновить приложение
592
+
593
+ 1. Найдите места, где приложение читает ответы прямо из хранилища (`storage.listMessages`, `storage.getMessage`, свой
594
+ SQL по таблице сообщений) и разбирает части ответа: показ, выгрузка, подсчёт, `parts[0]`. Теперь первой частью
595
+ ответа стоит служебная `data-frozen-context`. В ней память человека, дата и место, показывать её нельзя. Пропускайте
596
+ её:
597
+
598
+ Было:
599
+
600
+ ```ts
601
+ const parts = message.parts;
602
+ ```
603
+
604
+ Стало:
605
+
606
+ ```ts
607
+ import { FROZEN_CONTEXT_PART_TYPE } from "@ykurilov/agent-chat-core";
608
+
609
+ const parts = message.parts.filter((part) => part.type !== FROZEN_CONTEXT_PART_TYPE);
610
+ ```
611
+
612
+ `service.getHistory`, поток, `afterTurn` и `@ykurilov/agent-chat-history-search` её уже не отдают: там ничего делать
613
+ не нужно.
614
+ 2. Если у приложения есть свои тесты, которые сравнивают запрос к модели или его снимок (где стоит `<agent_memory>`,
615
+ «Сегодня: …», `<screen_place>`), обновите их. Блоки теперь в вопросе того хода, где их прочитали, а не в последнем
616
+ вопросе. Проверьте, что разница в снимке только в этом переносе.
617
+ 3. Если приложение подставляло в вопрос свои сведения на каждом ходе (скиллы, роль) через инструкцию или `stepContext`,
618
+ а они меняются редко, перенесите их в `agent.contextBlocks`: кэш модели перестанет сбрасываться. Шаг по желанию.
619
+ 4. Своя реализация `LongTermMemory` работает без правок. Если записей много и они часто меняются, добавьте метод
620
+ `blocks({ userId, chatId })`, который возвращает `[{ key: "memory", text: шапка, private: true }, { key: "memory:<id>",
621
+ text: запись, private: true }, …]`. Шаг по желанию.
622
+ 5. Проверка. Временно запишите в `afterTurn` расход хода:
623
+
624
+ ```ts
625
+ afterTurn: ({ messageId, usage }) => console.log("кэш", messageId, usage?.cachedInputTokens, "из", usage?.inputTokens),
626
+ ```
627
+
628
+ В одном чате с моделью, которая сообщает о кэше (Claude или Gemini через OpenRouter, DeepSeek), задайте вопрос, на
629
+ который агент вызовет инструмент, и через полминуты — второй вопрос. Ожидается: на втором ходе `cachedInputTokens`
630
+ не меньше входа последнего шага первого хода, то есть почти весь вход прочитан из кэша. Строк `[промах кэша]` в
631
+ журнале сервера (`logError`) нет. На экране служебных блоков нет, агент помнит записи памяти.
632
+
633
+ ## 0.34.0
634
+
635
+ ### Minor Changes
636
+
637
+ - d039ce4: Поправка на лету: человек пишет агенту, пока тот отвечает, и не ждёт конца ответа и не жмёт «Стоп».
638
+
639
+ Что это для человека: поле ввода во время ответа свободно, «Стоп» рядом с «Отправить». Поправка сразу видна в ленте с пометкой
640
+ «Агент учтёт на следующем шаге». Агент берёт её после очередных вызовов инструментов и продолжает с её учётом; сделанное до
641
+ поправки остаётся. Ответ кончился раньше — поправка запускает новый ход. Несколько поправок — все, по порядку. «Стоп» поправку
642
+ не теряет. Поправка хранится в базе и видна после обновления страницы.
643
+
644
+ Новое:
645
+
646
+ - ядро: `service.steer(userId, chatId, message, { page? })` → `{ status: "queued" | "started", messageId }`; в истории `pendingMessages`
647
+ (ждущие поправки); `isCorrection`, `STEER_PART_TYPE`, тип `SteerResult`; повтор с тем же `message.id` — тот же итог без второй копии;
648
+ - сервер: `POST …/:id/steer` `{ message, place?, tabId? }` → `202`;
649
+ - react: проп `steering` у `AgentChat` (включено, если скин рисует поправки; `false` — выключить), опция `steering` у
650
+ `useAgentChat` (по умолчанию выключено), у него же `canSteer`, `corrections`; надпись `steeringPending`
651
+ (русская по умолчанию, английская в `englishWorkLabels`);
652
+ - договор скина 21: `ChatSkin.steering?`, `Composer.canSteer?`, `UserMessage.pending?` ([docs/skin-v21.md](https://github.com/ykurilov/agent-chat-sdk/blob/main/docs/skin-v21.md));
653
+ - Тандем: во время ответа «Остановить» рядом с «Отправить» в рамке поля, пометка под ждущей поправкой. На узком экране
654
+ кнопка голосового режима на время ответа прячется, чтобы ряд не вылез за край.
655
+
656
+ ### Как обновить приложение
657
+
658
+ Обязательных шагов нет: со скином Тандема поправки включаются сами, свой скин и своя лента работают как раньше.
659
+
660
+ 1. Проверьте, что `@ykurilov/agent-chat-server` обновлён вместе с `@ykurilov/agent-chat-react`: старый сервер не знает `POST …/steer`,
661
+ и поправка вернётся в поле с ошибкой.
662
+ 2. Свой словарь надписей (полный объект, не поверх `russianLabels`) — добавьте `steeringPending`. Без неё будет русская надпись по умолчанию.
663
+ 3. Свой скин: без `steering: true` поле во время ответа занято, как раньше, — поправок у людей не будет. Чтобы были, поддержите
664
+ `Composer.canSteer` и `UserMessage.pending` и поставьте в скин `steering: true` по [docs/skin-v21.md](https://github.com/ykurilov/agent-chat-sdk/blob/main/docs/skin-v21.md).
665
+ 4. Своя лента на `useAgentChat`: включите `useAgentChat({ steering: true })`, рисуйте `corrections` после `messages`, пометкой — `steeringPending`.
666
+ 5. Несколько копий сервера: поправка принимается копией, на которой идёт ответ (как «Стоп»), иначе `409 busy`. Держите запросы
667
+ одного чата на одной копии.
668
+ 6. Не нужно со скином Тандема — `<AgentChat steering={false} />`.
669
+ 7. Проверка: `pnpm typecheck`; в чате во время долгого ответа отправьте поправку — она видна с пометкой, затем агент отвечает с её учётом.
670
+
671
+ ## 0.33.0
672
+
673
+ ### Minor Changes
674
+
675
+ - 4a72339: У провайдера OpenRouter появилась настройка `cacheTtl`: часовой кэш Anthropic (Claude через OpenRouter) вместо пятиминутного.
676
+
677
+ Что это: метки кэша получают `ttl: "1h"` — на инструкции, на последнем сообщении истории и перед блоками памяти и места (срок один на все метки: Anthropic не принимает часовую метку после пятиминутной). По умолчанию (`"5m"` или без настройки) метки без срока и запросы приложения не меняются ни на байт. Запись в часовой кэш дороже (у Anthropic 2× цены входа вместо 1.25×), чтение стоит столько же. Выгодно, когда между ходами чата больше 5 минут. Неверное значение — ошибка при создании провайдера. Настройка сохраняется при `connect()` и `connectProviders`.
678
+
679
+ Обновление ничего не требует. Чтобы включить часовой кэш:
680
+
681
+ 1. В каталоге моделей приложения замените провайдера OpenRouter:
682
+
683
+ ```ts
684
+ const catalog = extendCatalog(standardCatalog, {
685
+ addProviders: [createOpenRouterProvider({ /* прежние настройки */ cacheTtl: "1h" })],
686
+ });
687
+ ```
688
+
689
+ 2. Если нужна цена записи, поставьте в `pricing` профиля модели `cacheWritePerMillion` под часовой срок (2× цены входа у Anthropic).
690
+ 3. Проверка: `pnpm typecheck`; во втором запросе чата после паузы дольше 5 минут в usage есть прочитанный кэш (`cachedInputTokens`).
691
+
692
+ ## 0.32.0
693
+
694
+ ### Minor Changes
695
+
696
+ - 71c32b7: **Ломает приложения:** данные чата с символом NUL (`\u0000`) теперь дают `TypeError`, раньше SQLite и память их принимали.
697
+
698
+ Проверка одна для всех хранилищ: `createChat`, `openSubjectChat`, `importChat`, `updateChatData` и фильтр
699
+ `listChats({ data })`. Она касается и ключей, и строк на любой глубине. Раньше на PostgreSQL один такой чат ломал
700
+ поиск по данным во всём списке чатов человека, а SQLite и память молча хранили NUL.
701
+
702
+ ### Как обновить приложение
703
+
704
+ 1. Найдите в коде приложения места, где в данные чата попадает текст снаружи (формы, мессенджеры, импорт): поиск
705
+ по `createChat(`, `openSubjectChat(`, `importChat(`, `updateChatData(` и `listChats(` с полем `data`.
706
+ 2. Перед записью уберите из таких строк символ NUL.
707
+
708
+ Было:
709
+
710
+ ```ts
711
+ await chat.updateChatData(userId, chatId, { title: form.title });
712
+ ```
713
+
714
+ Стало:
715
+
716
+ ```ts
717
+ await chat.updateChatData(userId, chatId, { title: form.title.replaceAll("\u0000", "") });
718
+ ```
719
+
720
+ 3. Если такой текст может прийти в ключах объекта, чистите и ключи.
721
+ 4. Проверка: `pnpm typecheck` проходит; запись данных с `"a\u0000b"` в тесте приложения теперь падает с `TypeError`,
722
+ а с очищенной строкой проходит.
723
+ - a94bda9: **Ломает приложения:** `insertMessage` хранилища в памяти (`createInMemoryChatStorage`) бросает ошибку, если чата нет.
724
+
725
+ Раньше хранилище в памяти молча принимало сообщение в несуществующий чат, а PostgreSQL и SQLite отказывали. Тесты на
726
+ памяти пропускали ошибку, которая всплывала только на настоящей базе. Теперь все три хранилища ведут себя одинаково.
727
+
728
+ ### Как обновить приложение
729
+
730
+ 1. Найдите в тестах приложения прямые вызовы `insertMessage` на хранилище из `createInMemoryChatStorage()`.
731
+ 2. Перед первым сообщением создайте чат.
732
+
733
+ Было:
734
+
735
+ ```ts
736
+ const storage = createInMemoryChatStorage();
737
+ await storage.insertMessage(message);
738
+ ```
739
+
740
+ Стало:
741
+
742
+ ```ts
743
+ const storage = createInMemoryChatStorage();
744
+ await storage.createChat({ id: message.chatId, ownerId: "anna", modelId: "m", providerId: "p" });
745
+ await storage.insertMessage(message);
746
+ ```
747
+
748
+ 3. Код, который пишет сообщения через службу чата (`createChatService`, `createChatHandler`), менять не нужно.
749
+ 4. Проверка: тесты приложения проходят; ошибки `Чата … нет` в них не осталось.
750
+ - 27f4b1e: Удаление чата стирает состояние его окон лимитов (окна области `chat`: начало скользящего окна, перенос перерасхода,
751
+ отправленные пороги). Раньше эти записи оставались в базе навсегда. Расход в журнале и окна человека и сервиса не трогаются.
752
+
753
+ Новое: необязательный метод хранилища `deleteSpendingLimitStates(keys)` (есть в памяти, SQLite и PostgreSQL) и
754
+ необязательный `chatDeleted({ userId, chatId })` у `SpendingLimits` — служба чата зовёт его после удаления чата;
755
+ ошибка уходит в журнал сервера и удаление не отменяет.
756
+
757
+ ### Как обновить приложение
758
+
759
+ Ничего, если хранилище встроенное. Своё хранилище может добавить `deleteSpendingLimitStates`; без него записи окон
760
+ удалённых чатов просто остаются, как раньше.
761
+
762
+ ### Patch Changes
763
+
764
+ - cae13bf: Удаление чата во время хода больше не зовёт `onStateChanged` с пустым состоянием. Раньше хвост хода мог прочитать
765
+ уже удалённый чат и сообщить приложению, что состояние стало пустым, как будто его стёрли.
766
+
767
+ Обновление ничего не требует.
768
+ - 4e44492: Итог фоновой задачи больше не остаётся без ответа агента. Раньше, если сообщение с итогом записалось, а запуск хода
769
+ сорвался, повторная доставка отвечала «уже доставлено», и агент молчал. Теперь повтор будит агента, если после этого
770
+ сообщения в чате ещё ничего нет.
771
+
772
+ Обновление ничего не требует.
773
+ - ac9b7d3: «Стоп» больше не теряется, пока ход ещё запускается. Раньше остановка, нажатая во время проверок перед ходом
774
+ (`beforeTurn`, лимит трат), отвечала «нечего останавливать», а ход потом шёл до конца. Теперь `stop` в это время
775
+ возвращает `true`, и ход останавливается сразу после старта; адрес `/stop` отвечает 202.
776
+
777
+ Обновление ничего не требует.
778
+
779
+ ## 0.31.2
780
+
781
+ No changes in this release.
782
+
783
+ ## 0.31.1
784
+
785
+ No changes in this release.
786
+
787
+ ## 0.31.0
788
+
789
+ No changes in this release.
790
+
791
+ ## 0.30.0
792
+
793
+ No changes in this release.
794
+
795
+ ## 0.29.0
796
+
797
+ No changes in this release.
798
+
799
+ ## 0.28.0
800
+
801
+ ### Minor Changes
802
+
803
+ - 11c3c97: Состояние чата на ветке. Инструмент записывает данные разговора (поля анкеты, корзину, номер брони) в ответ агента рядом с
804
+ результатом вызова: `model.state.set("fields.email", "a@x.ru")`, `model.state.unset(path)`, `model.state.get(path?)`.
805
+ Состояние чата — сумма записей текущей ветки. Человек исправил старое сообщение или нажал «Переделать» — записи ушедших
806
+ ответов пропали из состояния; переключил ветку обратно — вернулись. Приложению для этого ничего делать не нужно.
807
+
808
+ - Сервер приложения читает состояние: `service.getChatState(userId, chatId)` → `{ values, atMessageId }`.
809
+ - Запись — часть ответа `data-app-state` (`{ set: { path: value } }` или `{ unset: [path] }`). Модели не уходит, на экране
810
+ не рисуется, запрос к модели и кэш не меняет. Новых таблиц и миграций нет.
811
+ - Только значения, без прибавок: повтор вызова после сбоя ничего не задваивает. Сжатие истории и «Повторить» оборванного ответа
812
+ состояние не теряют.
813
+ - Запись до 16 КБ, всё состояние до 256 КБ — больше `AppStateTooLargeError` внутри инструмента (модель видит текст).
814
+ Неверный путь или не простой JSON (`Map`, `Date`, `NaN`, `Infinity`…) — `TypeError`. Ключи без точек, массивы
815
+ целиком: путь через строку или массив (`items.0`) — `TypeError`. После конца хода запись не принимается, строка в
816
+ `logError`.
817
+ - Верхние ключи на `$` — служебные ключи SDK: приложение их не пишет и не видит.
818
+ - Для своих частей и проверок: `foldAppState`, `loadBranchAppState`, `createAppStateWriter`, `appStatePart`,
819
+ `publicAppState`, `APP_STATE_PART_TYPE`; типы `AppStateAccess`, `AppStateValues`, `AppStateRecord`, `ChatState`.
820
+ - `AgentToolsModel.state` необязательное: вне хода (сжатие, фоновая задача) его нет. Тесты, собирающие
821
+ `AgentToolsModel` вручную, могут его не задавать.
822
+
823
+ Обновление ничего не требует. Своя реализация `ChatService` (если есть) добавляет метод `getChatState`. Подробно и с
824
+ примером — `docs/in-product-agent.md`, раздел «Состояние чата на ветке».
825
+ - d63ccaa: Ломает приложения: закрытие чата теперь запись на ветке, а не флаг навсегда. Человек исправил сообщение до точки
826
+ закрытия, нажал «Переделать» или перешёл на другую ветку — на новой ветке чат открыт. Вернулся стрелкой назад — снова
827
+ закрыт. Отправка, «Повторить», подтверждения и виджеты в закрытом чате по-прежнему запрещены (409 `chat_closed`).
828
+
829
+ Плюс приложение узнаёт о смене состояния чата (`model.state`): на сервере — хук, на экране — проп и хук React.
830
+
831
+ - **Сервер:** `onStateChanged({ ownerId, chatId, reason, state, previousState, atMessageId })` у `createChatServer` и
832
+ `createChatService`. Зовётся после хода и смены ветки (`reason`: `turn`, `editMessage`, `regenerate`, `retry`,
833
+ `selectBranch`, `editAnswer`), только если состояние сменилось. Ход его не ждёт. Вызовы одного чата — по очереди.
834
+ Сбой — повторы (`onStateChangedAttempts`, 3) в сроке (`onStateChangedTimeoutMs`, 30 000), потом строка в `logError`.
835
+ Доставка только пока сервер жив: упал между ответом и хуком — хук не позовётся, сверяйте по `getChatState`.
836
+ - **События:** `state-changed` `{ chatId, reason, state, atMessageId }` (вкладка получает через `watch`) и
837
+ `state-changed-failed`. `chat-closed` и `chat-reopened` теперь приходят и при смене ветки.
838
+ - **Экран:** `<AgentChat onStateChange={(state) => …} />` — после загрузки и при каждой смене, в том числе по ходу
839
+ ответа и из другой вкладки (событие `state-changed`). Внутри ленты — `useChatState()`, у `useAgentChat` — `chatState`. Тип `ChatStateValues`.
840
+ - **Ответ сохраняется в базу сразу после итога каждого инструмента**, а не раз в полсекунды: запись состояния
841
+ переживает падение сервера. Промежуточные результаты (`preliminary`) — по-прежнему раз в полсекунды.
842
+ - **Служебные ключи** состояния (`$…`) больше не уходят в браузер: ни в истории, ни в потоке ответа.
843
+ - **`getChatState` дешёвый:** читает только связи сообщений и записи состояния. SQLite и Postgres получили метод
844
+ `listMessagePartsOfTypes(chatId, types)`. Новых таблиц и миграций нет.
845
+ - **Колонка `closed`** в записи чата — копия закрытия текущей ветки для `listChats`. `chatCapabilities` (`chat.closed`)
846
+ и история берут закрытие с ветки. Не записалась копия — её дописывает следующее открытие истории.
847
+ - **Чаты, закрытые в 0.27,** остаются закрытыми. Перенос в запись ленивый, без миграции: запись появится при первой
848
+ правке, «Переделать», выборе ветки или правке ответа в этом чате.
849
+ - Ядро экспортирует `CLOSED_STATE_KEY`, `isStateMessage`, `publicAppStateParts`, `DEFAULT_STATE_CHANGED_ATTEMPTS`,
850
+ `DEFAULT_STATE_CHANGED_TIMEOUT_MS`, типы `StateChangedHandler`, `StateChangedInput`, `StateChangeReason`,
851
+ `MessagePartsLink`.
852
+
853
+ ### Как обновить приложение
854
+
855
+ 1. **Закрытие должно быть навсегда** (нельзя уйти правкой или веткой): в настройках сервера (`createChatServer`)
856
+ добавьте права по закрытию.
857
+
858
+ Было: ничего.
859
+
860
+ Стало:
861
+
862
+ ```ts
863
+ chatCapabilities: ({ chat }) =>
864
+ chat.closed ? { edit: false, regenerate: false, branches: false, answerEditing: false } : {},
865
+ ```
866
+
867
+ Если `chatCapabilities` уже есть — допишите эту ветку в него. Если правка до закрытия вам подходит — шаг пропустите.
868
+ 2. **`switch` по событиям чата** (`ChatEvent`, `service.events`) с проверкой полноты (`never`): добавьте ветки
869
+ `state-changed` и `state-changed-failed`.
870
+ 3. **Своё хранилище** (не SQLite и не Postgres): ничего не обязательно. Для дешёвого чтения состояния добавьте
871
+ `listMessagePartsOfTypes(chatId, types)` → `[{ id, parentId, createdAt, parts }]` в порядке `listMessages`, только
872
+ части нужных типов. Общие тесты хранилища проверят его, флаг `partsOfTypes: "required"` делает его обязательным.
873
+ `setChatClosed` теперь пишет только копию для списка: без него закрытие работает, но список его не видит.
874
+ 4. **Своя реализация `ChatService`** (заглушки в тестах): полей не прибавилось. Если вы проверяли, что правка или
875
+ «Переделать» в закрытом чате дают `ChatClosedError`, — теперь они идут.
876
+ 5. **Таблица-копия состояния** (по желанию): `onStateChanged` с полной заменой записи —
877
+ `await db.upsertForm({ chatId, fields: state.fields ?? {} })`.
878
+ 6. Проверка: `pnpm typecheck`; закройте чат инструментом, исправьте вопрос до закрытия — без шага 1 чат открыт, с
879
+ шагом 1 правка недоступна.
880
+ - 6a2f903: Снимок перед каждым шагом модели: `agent.stepContext`. Приложение собирает текст (например, «что уже записано в
881
+ заявке и чего не хватает»), и он ложится сообщением человека в самый конец запроса шага — после результатов
882
+ инструментов. Инструмент записал поле на шаге 1 — на шаге 2 модель уже видит его в снимке. В историю снимок не пишется.
883
+
884
+ ```ts
885
+ agent: {
886
+ instructions, // скажите здесь, что «Служебный снимок сервера» присылает сервер
887
+ stepContext: async ({ state, chat, stepNumber }) =>
888
+ `Служебный снимок сервера:\n${JSON.stringify({ saved: state.fields ?? {} })}`,
889
+ }
890
+ ```
891
+
892
+ - Функция видит `state` (живое состояние ветки: записи прошлых шагов уже в нём, без служебных ключей), `stepNumber`
893
+ (номер шага во всём ответе, с нуля: после подтверждения счёт продолжается), `chatId`, `ownerId`, `messageId`,
894
+ `chat.data`, `subject`, `channel`, `audience`, `staff` (как у инструментов), `modelId`, `providerId`, `abortSignal`.
895
+ Данные и объект чата — копии. Текст целиком ваш.
896
+ - В групповом чате мессенджера (`audience: "group"`) снимок может уйти в ответ при всех: личного в нём не показывайте.
897
+ - `agent.stepContextTimeoutMs` — срок функции, по умолчанию 5000 мс; 0 или `Infinity` — без срока, иначе от 1000.
898
+ Не ответила — шаг без снимка, `abortSignal` отменён, строка `[снимок шага] … не ответил за N мс` в `logError`.
899
+ «Стоп» отменяет функцию сразу.
900
+ - Кэш модели цел: снимок на шаге один и всегда в конце, всё до него — те же байты, что на прошлом шаге. Метки кэша не
901
+ двигаются.
902
+ - Последний шаг хода: снимок, затем пометка «шаги кончились». В запросы сжатия, памяти и помощников снимок не идёт.
903
+ - `null` или пустая строка — шаг без снимка. Бросок — шаг без снимка, строка `[снимок шага]` в `logError`, ход идёт дальше.
904
+ - Живая проверка на всех парах стандартного каталога: провайдеры принимают сообщение человека сразу после результата
905
+ инструмента. Скажите модели в инструкции, что снимок присылает сервер: иначе осторожная модель изредка ему не верит.
906
+ - Журнал промахов кэша не считает снимок промахом.
907
+ - Новые типы `StepContext`, `StepContextInput`.
908
+
909
+ Обновление ничего не требует: без `stepContext` запрос побайтно прежний. Подробно — `docs/in-product-agent.md`, раздел
910
+ «Состояние чата на ветке», «Снимок перед каждым шагом».
911
+ - 189cba9: Проверка ответа виджета на сервере. Раньше ответ проверялся только схемой. Теперь приложение может проверить его с
912
+ доступом к своей базе («это время уже заняли»), отказать человеку своими словами, подменить ответ и записать состояние чата.
913
+
914
+ ```ts
915
+ agent: {
916
+ widgets: {
917
+ custom: [
918
+ {
919
+ ...pickSlot, // виджет из общего файла; проверка — только на сервере
920
+ checkAnswer: async (answer, ctx) => {
921
+ const hold = await bookings.tryHold({ key: ctx.toolCallId, chatId: ctx.chatId, start: answer.slot });
922
+ if (!hold) return { refuse: "Это время уже заняли. Выберите другое." }; // виджет остаётся открытым
923
+ ctx.state.set("booking.holdId", hold.id); // ляжет в ответ вместе с результатом виджета
924
+ return { answer: { ...answer, holdId: hold.id } }; // подмена ответа; можно вернуть и undefined — принять как есть
925
+ },
926
+ },
927
+ ],
928
+ },
929
+ }
930
+ ```
931
+
932
+ - Проверка зовётся после схемы, пока чат удержан за этим ответом. `ctx`: `ownerId`, `chatId`, `messageId`, `toolName`,
933
+ `toolCallId`, `input`, `subject?`, `chatData?`, `state` (запись, как `model.state`), `signal` (`AbortSignal`). Исходы: `undefined` — принять,
934
+ `{ answer }` — принять с заменой (её видят модель и экран; до 16 КБ, схемой не перепроверяется), `{ refuse }` — отказать
935
+ текстом до 300 знаков.
936
+ - Общая проверка для всех виджетов без своей, в том числе базовых (`ask_choice` и др.):
937
+ `agent.widgets.checkAnswer(call, answer, ctx)`, где `call` — `{ toolName, toolCallId, input }`.
938
+ - Отказ: служба бросает `WidgetAnswerRefusedError`, сервер отвечает `409 { error: { kind: "widget_answer_refused", code,
939
+ message } }` (новый вид в `HandlerErrorKind`), экран показывает текст под виджетом и оставляет виджет открытым; поле ввода и
940
+ `onTurnRefused` не трогает. Ничего не записано, повтор ответа возможен.
941
+ - Записи `ctx.state` принятого ответа ложатся частями `data-app-state` вместе с результатом виджета; отказ и исключение их
942
+ отбрасывают. `getChatState` и следующий ход видят их.
943
+ - Исключение проверки — ответ 500, ничего не записано, виджет открыт, в `logError` строка
944
+ `[виджет] ошибка проверки чат=… вызов=…` с именем ошибки, без её текста.
945
+ - Срок проверки (`agent.widgets.checkAnswerTimeoutMs`, по умолчанию 5 000 мс) — не сбой: служба бросает
946
+ `WidgetAnswerCheckTimeoutError`, сервер отвечает `504 { error: { kind: "widget_check_timeout", code, message } }` (новый вид
947
+ в `HandlerErrorKind`), ничего не записано, виджет открыт, в журнале строка `[виджет] отказ answer_check_timeout …`, а не
948
+ ошибка. Страница показывает под виджетом новую подпись `widgets.answerCheckTimeout` («Не удалось проверить ответ,
949
+ попробуйте ещё раз»; её можно переопределить в `labels`), у `WidgetFailureKind` новый вид `check_timeout`. Зависшая
950
+ проверка не держит чат занятым. Срок проверки держите заметно меньше `requestTimeoutMs` страницы (10 000 мс).
951
+ - `ctx.signal` (`AbortSignal`) срабатывает по сроку проверки и по «Стопу» в чате. После отмены служба ничего не пишет,
952
+ виджет остаётся открытым, но остановить работу приложения она не может: слушайте сигнал сами
953
+ (`fetch(url, { signal: ctx.signal })`). «Стоп» во время проверки — не сбой: служба бросает новую
954
+ `WidgetAnswerCheckAbortedError`, сервер отвечает `409 { error: { kind: "widget_answer_cancelled", code, message } }`
955
+ (новый вид в `HandlerErrorKind`), в журнале строка `[виджет] отказ answer_cancelled …`, страница тихо оставляет виджет
956
+ открытым, без надписи под ним.
957
+ - Чат закрыли (`closeChat`) во время проверки: ответ не пишется, служба бросает `ChatClosedError`, ждущий виджет закрывается
958
+ отказом, как при обычном закрытии.
959
+ - Для React: у `WidgetFailureKind` новый вид `answer_refused` (текст отказа в `text`). Скины, которые перебирают виды отказа
960
+ полным `switch`, добавляют ветку; готовый скин и `AgentChat` ничего не требуют.
961
+ - Без `checkAnswer` запрос к модели, хранилище и ответы сервера прежние.
962
+
963
+ Обновление ничего не требует. Если приложение разбирает ответы сервера полным `switch` по `HandlerErrorKind`, добавьте
964
+ `widget_answer_refused`, `widget_answer_cancelled` и `widget_check_timeout`. Подробности и пример — `docs/widgets.md`, раздел «Проверка ответа на сервере».
965
+
966
+ ## 0.27.0
967
+
968
+ ### Minor Changes
969
+
970
+ - d8341b2: Свои данные чата (`chat.data`) и защита таблиц SQLite. Приложения без новых опций работают как раньше: `subjectData` остался и равен `data`.
971
+
972
+ - **`chat.data`** — JSON-объект до 16 КБ у любого чата (раньше такие данные были только у чатов объектов). Лежит в том же столбце `subject_data`: миграции нет, старые данные чатов объектов уже читаются как `data`. В браузер и в запрос к модели данные не уходят: кэш модели не меняется.
973
+ - **Новый чат:** `service.createChat(userId, { data })`. Из браузера: `createChatServer({ newChatData: ({ user, request }) => data })` — данные ставит сервер, браузер их не присылает. Вернули не объект — 500. Хранилище без `updateChatData` с `newChatData` — ошибка при создании обработчика; `createChat({ data })` на таком хранилище — та же понятная ошибка, данные молча не теряются.
974
+ - **Правка:** `service.updateChatData(userId, chatId, patch)` — сливает верхние ключи (ключ со значением `undefined` удаляется, `null` остаётся значением). Итог больше 16 КБ — `TypeError`, данные не меняются. Возвращает новые данные. Правка атомарная: две одновременные правки разных ключей не затирают друг друга. Новое значение видно со следующего хода. Верхний ключ `__proto__` в данных, правке и фильтре не принимается (`TypeError`): иначе значение тихо пропало бы при слиянии.
975
+ - **Список:** `service.listChats(userId, { data: { formId: "f1", mode: "test" } })` — чаты, у которых все указанные верхние ключи равны значениям. Значения только строка, число, булево или `null` (иначе `TypeError`); `1` и `"1"` — разные значения. Можно вместе с `subjects`. SQLite ищет через `json_each`, PostgreSQL — через `jsonb @>`.
976
+ - **Кто видит данные:** `beforeTurn` и `chatInstructions` — `chat.data`; инструменты — `model.chat.data` (в том числе при сжатии и у фоновых помощников); `afterTurn` — `turn.chatData` (данные на момент итога: правка посреди хода видна, и итог после перезапуска даёт то же). `chatCapabilities`, проверка виджета и `stepContext` получают `Chat` и тоже видят `data`.
977
+ - **Событие `chat-data-changed { ownerId, chatId }`** (без данных) для `onChatEvent`. Вкладке браузера оно не пересылается.
978
+ - **Своё хранилище:** добавьте необязательный метод `updateChatData(chatId, patch, { maxBytes })` (одна атомарная операция, возвращает чат или `null`; больше предела — `ChatDataTooLargeError`), `listChats(ownerId, { subjects?, data? })` и запись/чтение `data` (у старого кода — `subjectData`). Без `updateChatData` служба отвечает понятной ошибкой на `updateChatData` и `listChats({ data })`. Проверка — `describeChatStorageContract(…, { chatData: "required" })`.
979
+ - **Песочница:** в копии чата человека инструменты и `afterTurn` видят данные исходного чата, а не служебную обёртку копии; это верно и для обычного чата без ключа объекта (`subject` у него по-прежнему нет).
980
+ - **SQLite: защита от чужих таблиц.** Таблицы SDK не переименованы: SDK должен жить в своём файле. При открытии, до миграций, SDK смотрит, нет ли в файле чужих `chats` или `messages` (журнал миграций SDK не знает их). Тогда `createSqliteChatStorage` бросает `ForeignChatTablesError` с текстом «дайте SDK отдельный файл», файл не тронут. Если у приложения свои `chats`/`messages` в том же файле — укажите SDK другой `filePath`. Раздел «Где лежат данные SDK» — в README хранилищ.
981
+ - 5e4ed28: **Что может сломаться:**
982
+
983
+ - исчерпывающий `switch` по виду ошибки сервера (`HandlerErrorKind`) и виду отказа на экране (`AppRefusalKind`): новые значения `action_forbidden`, `chat_closed`, `chat_creation_refused`;
984
+ - исчерпывающий `switch` по причине `turnNotStarted` (`TurnNotStartedReason`): новое значение `chat_closed`;
985
+ - свой словарь надписей `ChatLabels`, собранный целиком: новое обязательное поле `chatListClosed`.
986
+
987
+ Права человека в чате и закрытый чат. Приложение функцией `chatCapabilities` решает, что можно человеку: правка, «Переделать», «Повторить», ветки, выбор модели, правка ответа, список чатов, сжатие, файлы, голос. Чат можно закрыть из инструмента (`model.closeChat`) или из кода (`service.closeChat`, `reopenChat`): переписка видна, писать дальше нельзя. Хук `beforeCreateChat` решает, можно ли человеку завести новый чат. Без новых настроек всё работает как раньше. Подробно — [docs/in-product-agent.md](https://github.com/ykurilov/agent-chat-sdk/blob/main/docs/in-product-agent.md), разделы 9, 10 и 11.
988
+
989
+ - Права: опечатка в названии права (`edits` вместо `edit`) — ошибка приложения (`TypeError`), а не молчаливый доступ. За один вызов службы права спрашиваются не больше раза.
990
+ - Закрытие побеждает паузу: если чат закрыт, пока ответ ждёт подтверждения или виджета, ждущие запросы закрываются отказом, как при «Стопе», и ответ считается законченным.
991
+ - Закрытие из инструмента записывается сразу при вызове: после падения сервера чат уже закрыт. Чат, закрытый во время проверки перед ходом, хода не начинает: 409 `chat_closed`, резерв приложения закрывается `turnNotStarted` с причиной `chat_closed`.
992
+ - Новый чат: `beforeCreateChat({ userId, origin? })` вернул `{ code, message }` — `POST {base}` отвечает `403 { error: { kind: "chat_creation_refused", code, message } }`, мессенджер (`/new`, первый чат) — текстом. Чаты объектов и импорт хук не спрашивают.
993
+ - `listChats` отдаёт `closed`: на экране и в `/chats` мессенджера у закрытого чата пометка.
994
+ - Сервер: запрещённое действие — `403 { error: { kind: "action_forbidden", action, code, message } }`, закрытый чат — `409 { error: { kind: "chat_closed", code, message } }`. Мессенджеры отвечают человеку текстом.
995
+ - Мессенджеры: голосовое при `voice: false` и файлы при `attachments: false` не скачиваются и не распознаются, `/chats` смотрит на `chatList`. Своя служба может реализовать необязательный `assertCapability`.
996
+ - Песочница: закрытие работает как в бою, а кнопка «Открыть чат снова» (`POST {sandboxBase}/chats/:chatId/reopen`, только админ) позволяет продолжить тест. Скину песочницы — необязательный `SandboxSkin.ChatReopen`, надписи `SandboxLabels.chatReopen` (необязательные); в Тандеме кнопка своя.
997
+ - Экран: кнопки по правам прячутся сами, после ответа 403 `action_forbidden` права перечитываются, закрытый чат блокирует поле и показывает текст закрытия. Голосовое сообщение при `voice: false` не уходит. Новое:
998
+ - `AppRefusalKind` — `chat_creation_refused`; `useChatList` — `onCreateRefused` и `creationRefusal`, `ChatWorkspace` отдаёт отказ в `onTurnRefused`;
999
+ - `ChatListEntry.closed` и `ChatListItemView.closed` — пометка закрытого чата в списке, надпись `chatListClosed` («Закрыт», обязательное поле `ChatLabels`);
1000
+ - `reloadKey` у `AgentChat`, `useAgentChat` и `ChatWorkspace` — перечитать историю, когда чат изменили не через экран;
1001
+ - `onChatUnavailable` у `AgentChat` — служебный колбэк для `ChatWorkspace`, приложению не нужен;
1002
+ - `ChatWorkspace` при `chatList: false` не мигает списком: он появляется после первой истории (и при `chatList: true` — тоже с этой короткой задержкой) и потом не запрашивается;
1003
+ - `onTurnRefused` получает новые виды отказов, `onChatAccessChange` сообщает права и закрытие, `ChatWorkspace` при `chatList: false` показывает только чат.
1004
+ - Скин: договор v20 — необязательный `ChatClosedNotice` и необязательный `list` у `ChatListSkin.ChatWorkspace` ([docs/skin-v20.md](https://github.com/ykurilov/agent-chat-sdk/blob/main/docs/skin-v20.md)). Скин v19 работает без правок. В Тандеме — `TandemChatClosedNotice`.
1005
+ - Хранилища: колонка `closed` у чатов, миграция SQLite и PostgreSQL применяется сама. Своё хранилище может реализовать необязательный `setChatClosed(chatId, closed | null)` и отдавать `closed` в `getChat`; общий тест — `describeChatStorageContract(…, { chatClosure: "required" })`.
1006
+
1007
+ ### Как обновить приложение
1008
+
1009
+ 1. Найдите в коде приложения исчерпывающие `switch` по виду ошибки сервера чата, по `refusal.kind` из `onTurnRefused` и по `reason` в `turnNotStarted`. Искать: `kind: "turn_refused"`, `case "turn_refused"`, `case "access_refused"`, `HandlerErrorKind`, `AppRefusalKind`, `TurnNotStartedReason`, `case "busy"`.
1010
+ 2. Добавьте ветки для новых видов:
1011
+
1012
+ Было:
1013
+
1014
+ ```ts
1015
+ switch (refusal.kind) {
1016
+ case "turn_refused": return showLessonClosed(refusal.message);
1017
+ case "access_refused": return showNoAccess(refusal.message);
1018
+ default: { const never: never = refusal.kind; return never; }
1019
+ }
1020
+ ```
1021
+
1022
+ Стало:
1023
+
1024
+ ```ts
1025
+ switch (refusal.kind) {
1026
+ case "turn_refused": return showLessonClosed(refusal.message);
1027
+ case "access_refused": return showNoAccess(refusal.message);
1028
+ case "chat_closed":
1029
+ case "action_forbidden": return; // чат уже показал текст сам
1030
+ case "chat_creation_refused": return showToast(refusal.message);
1031
+ default: { const never: never = refusal.kind; return never; }
1032
+ }
1033
+ ```
1034
+
1035
+ В `switch` по `reason` в `turnNotStarted` добавьте `case "chat_closed":` рядом с `case "busy":`: резерв снимается так же.
1036
+
1037
+ 3. Свой словарь `ChatLabels`, собранный целиком (не поверх `russianChatLabels`): добавьте `chatListClosed: "Закрыт"`.
1038
+ 4. Своё хранилище чата (не SQLite и не PostgreSQL из SDK): если нужен закрытый чат, реализуйте `setChatClosed` и отдавайте `closed` в `getChat`. Без этого `closeChat` бросает ошибку, а инструменты не получают `model.closeChat`.
1039
+ 5. Проверка: `pnpm typecheck` проходит, чат открывается и отвечает.
1040
+
1041
+ Заметка к 0.28: закрытие станет записью хода, и правка, «Переделать» и ветки в закрытом чате станут возможны. Чтобы сохранить нынешнее поведение, задайте права: `chatCapabilities: ({ chat }) => chat.closed ? { edit: false, regenerate: false, branches: false } : {}`.
1042
+ - e0bb519: Маршруты OpenRouter и переход на запасного провайдера посреди ответа.
1043
+
1044
+ - **Маршруты OpenRouter.** `createOpenRouterProvider({ id, displayName, only, order, allowFallbacks, requireParameters })`.
1045
+ Маршрут с `id` — отдельный провайдер каталога: например, `or-deepseek` только с хозяином `deepseek` и
1046
+ `or-fireworks` только с `fireworks`. Он обслуживает те же модели, что `openrouter`: id моделей, цены, закреплённые
1047
+ версии и отметка «видит картинку в результате инструмента» переходят сами. Своя запись модели для маршрута главнее.
1048
+ Провайдер `openrouter` должен остаться в каталоге, иначе ошибка при сборке каталога. Маршруты попадают и в порядок
1049
+ запасных провайдеров по умолчанию. С `order` сортировка `sort: "throughput"` по умолчанию больше не ставится; явный
1050
+ `sort` остаётся. Один хозяин и в `only`, и в `ignore` — ошибка при создании; все хозяева `only` запрещены профилем
1051
+ модели — понятная ошибка запроса.
1052
+ - **Переход посреди ответа** (по желанию): `agent.providerFallback: { onSilence: "restart-step-on-next" }`. Модель
1053
+ замолчала дольше `modelSilenceTimeout`, до первого куска или посреди ответа. Тогда шаг обрывается и начинается заново
1054
+ у следующего провайдера списка `byModel`, без повтора у того же. Показанный текст шага стирается: клиент получает часть
1055
+ `data-step-reset` («убрать всё от последней части `step-start`»). В сохранённом ответе и в запросе модели стёртого
1056
+ нет. `useAgentChat` и мессенджеры убирают стёртое сами, и решения хука («Повторить», «Ответить заново») стёртого не
1057
+ видят. Своя лента на сырых частях — `withoutResetSteps(parts)` из core или react. Ход с переходом сразу после `start`
1058
+ присылает кусок `data-steps-may-reset` (только клиенту): мессенджер по нему держит части идущего шага в живом
1059
+ сообщении до конца шага, а стёртый целиком текст заменяет фразой «Пишу ответ заново…».
1060
+ Шаг, где инструмент уже получил вход целиком, не стирается: ответ продолжается у запасного.
1061
+ `scope: "step"` — следующий шаг снова у основного провайдера (по умолчанию `"turn"`); только после перехода при
1062
+ молчании, запасной по перегрузке остаётся до конца хода. Перегрузка у запасного ведёт к следующему за ним.
1063
+ `maxSwitches` — переходов за ход, по умолчанию 1; каждый съедает шаг из предела шагов. Стёртый шаг оплачен и есть в
1064
+ расходе хода и в записи после перезапуска сервера. Срок хода `turnTimeoutMs` идёт и во время перехода;
1065
+ `maxOutputTokens` действует в каждом заходе; после начала `flush` новых заходов нет (`stopReason: "shutdown"`);
1066
+ при «Стоп» или остановке службы перехода нет и показанное не стирается. В журнале сервера — строка
1067
+ `[переход при молчании]`.
1068
+ - **Остановка службы — для всех приложений.** С этой версии `flush` с первых мгновений ожидания идущих ответов
1069
+ запрещает ходам новые заходы: к запасному провайдеру, автопродолжение, продолжение после сжатия. Ход, которому нужен новый
1070
+ заход, кончается остановкой (`stopReason: "shutdown"`), даже без `onSilence`. Раньше такой ход мог начать новый
1071
+ заход посреди остановки сервера.
1072
+
1073
+ ### Как обновить приложение
1074
+
1075
+ Ничего делать не нужно: без `onSilence` всё работает как раньше. Включить переход — `agent.providerFallback:
1076
+ { byModel: { "deepseek-4.1-flash": ["or-fireworks"] }, onSilence: "restart-step-on-next" }` и маршруты в
1077
+ `extendCatalog(..., { addProviders: [...] })`. Если лента строится на сохранённых или сырых частях без `useAgentChat` —
1078
+ пропускайте их через `withoutResetSteps` (есть в core и в react). Если маршрут OpenRouter задаёт `order` и рассчитывал
1079
+ на сортировку по скорости, задайте `sort: "throughput"` явно. Проверка: `pnpm typecheck`.
1080
+
1081
+ ## 0.26.2
1082
+
1083
+ ### Patch Changes
1084
+
1085
+ - f24858a: Вопрос без ответа больше не сливается со следующим. Раньше, если ход падал сбоем модели до первых слов (или «Стоп»
1086
+ приходился на рассуждение), следующее сообщение человека склеивалось с прежним в одно: модель читала
1087
+ «Первое сообщениеВторое сообщение» как один вопрос.
1088
+
1089
+ Теперь в запросе к модели это два отдельных сообщения человека, а между ними — ответ модели с постоянной пометкой
1090
+ `[Ответа на это сообщение нет: ход оборвался до первых слов]`. Пометка только в запросе: в историю не пишется, человек
1091
+ её не видит. Строгие провайдеры по-прежнему получают сообщения человека и модели по очереди.
1092
+
1093
+ - **Кэш модели.** Пометка встаёт после вопроса без ответа и зависит только от истории: на каждом ходе те же байты, уже
1094
+ отправленное начало запроса не меняется. Один раз после обновления изменится запрос только у тех чатов, где такая
1095
+ склейка уже была, — один промах кэша на чат.
1096
+ - **«Повторить» после сбоя** — как раньше: ответ на тот же вопрос, он в запросе один раз, пометки нет.
1097
+ - **Правка.** Правка следующего сообщения оставляет вопрос без ответа и пометку перед ним. Правка самого вопроса без
1098
+ ответа начинает ветку до него — ни его, ни пометки в запросе нет.
1099
+
1100
+ **Что сделать в приложении:** ничего, достаточно обновить пакеты. Отдельный метод «отменить неотвеченное» не понадобился:
1101
+ «Повторить» уже отвечает на тот же вопрос, а правка вопроса без ответа убирает его из ветки.
1102
+
1103
+ ## 0.26.1
1104
+
1105
+ No changes in this release.
1106
+
1107
+ ## 0.26.0
1108
+
1109
+ ### Minor Changes
1110
+
1111
+ - eb43e47: Пределы хода и честная остановка сервера. Без новых опций запрос к модели прежний. Поменялось одно: `flush()` теперь
1112
+ закрывает идущие ответы как «Стоп», а не оставляет их «идущими» до следующего старта.
1113
+
1114
+ - **Общий срок хода** — `agent.turnTimeoutMs`: число мс или `{ default?, byModel? }`. Срок вышел — ход останавливается,
1115
+ как по «Стопу»: написанное цело, `status: "stopped"`, а `afterTurn` получает `stopReason: "timeout"`. «Стоп» человека —
1116
+ `stopReason: "user"`, остановка сервера — `"shutdown"`. 0 или `Infinity` — без срока. Иначе от 1000 до 2³¹−1. Модель из
1117
+ `byModel`, которой нет в каталоге, — ошибка при создании службы. Не задано — без срока. Срок считается до записи итога:
1118
+ `afterTurn` записанного ответа он не обрывает.
1119
+ - **Предел токенов ответа** — `agent.maxOutputTokens`: целое от 1 или `{ default?, byModel? }`. Уходит провайдеру в каждом
1120
+ запросе к модели (в каждом шаге хода). Ответ упёрся — `afterTurn` получает `lengthCut: true`. Не задано — предела в
1121
+ запросе нет, кэш модели не меняется.
1122
+ - **`turnNotStarted`** — новый хук службы и `createChatHandler`: `({ userId, chat, action, messageId?, text?, reason })`. Он
1123
+ зовётся, когда `beforeTurn` пропустил ход, а ход не начался. Причины: `busy`, `too_many_turns`, `spending_limit`,
1124
+ `duplicate_message`, `invalid`, `no_turn`, `error`. Каждый пропуск `beforeTurn` закрывается ровно одним: `afterTurn`
1125
+ начатого хода или `turnNotStarted`. Так резерв приложения больше не повисает. Ответ клиенту прежний (409, 429…), бросок
1126
+ хука уходит в `logError`. Порядок проверок прежний: отказ приложения (403) по-прежнему приходит раньше «занято» (409).
1127
+ - **`beforeTurn` получает `messageId`** — id сообщения человека у `send` и `edit`.
1128
+ - **`flush({ waitForAnswersMs })`** у службы, `createChatHandler` и песочницы. Пока идёт остановка, новые ходы не
1129
+ начинаются: чат занят. Идущие ответы ждутся до срока (по умолчанию 0). Не успевшие закрываются как «Стоп»: написанное
1130
+ записывается, обрывков нет, `afterTurn` видит `stopReason: "shutdown"`. `flush` возвращается, когда все ответы записаны и
1131
+ хуки `afterTurn` (в своём сроке), сжатие, память и фон закончены. Только после этого можно закрывать хранилище. Неверный
1132
+ срок — `TypeError`. Остановленный ход, который за 10 с так и не записал итог (база висит), `flush` больше не ждёт:
1133
+ строка в `logError`, ответ прервёт восстановление при следующем старте. Не прошла конечная запись — ответ помечен
1134
+ брошенным, и любая копия прерывает его сразу, без ожидания порога.
1135
+ - Новые типы: `TurnTimeoutSetting`, `MaxOutputTokensSetting`, `TurnNotStartedHandler`, `TurnNotStartedReason`,
1136
+ `TurnStopReason`, `FlushOptions`. В `TurnResult` — `stopReason?`, `lengthCut?`.
1137
+
1138
+ ### Как обновить приложение
1139
+
1140
+ 1. Остановка сервера (обработчик `SIGTERM`, где зовётся `chat.flush()`): дайте ответам доработать.
1141
+
1142
+ Было:
1143
+
1144
+ ```ts
1145
+ server.close();
1146
+ await chat.flush();
1147
+ await storage.close();
1148
+ ```
1149
+
1150
+ Стало:
1151
+
1152
+ ```ts
1153
+ server.close();
1154
+ await chat.flush({ waitForAnswersMs: 20_000 }); // ждёт ответы и afterTurn; не успевшие — «Стоп» с записью
1155
+ await storage.close();
1156
+ ```
1157
+
1158
+ Без опций `flush()` тоже работает, но идущие ответы останавливаются сразу (`status: "stopped"`, раньше они становились
1159
+ `interrupted` при следующем старте). Если приложение ждало `interrupted` после остановки, ждите `stopped` со
1160
+ `stopReason: "shutdown"`.
1161
+ 2. Если `beforeTurn` резервирует что-то (попытку, деньги), снимайте резерв в `turnNotStarted`. Связать резерв с ходом
1162
+ удобно по `messageId`:
1163
+
1164
+ ```ts
1165
+ beforeTurn: async ({ userId, messageId }) => { await reserveAttempt(userId, messageId); },
1166
+ turnNotStarted: async ({ userId, messageId }) => { await releaseAttempt(userId, messageId); },
1167
+ ```
1168
+ 3. По желанию задайте пределы хода в `agent`:
1169
+
1170
+ ```ts
1171
+ agent: { turnTimeoutMs: 180_000, maxOutputTokens: { default: 4_000, byModel: { "deepseek-4.1-flash": 2_000 } } },
1172
+ ```
1173
+
1174
+ В `afterTurn` отличайте `stopReason` (`user` / `timeout` / `shutdown`) и `lengthCut`.
1175
+ 4. Проверка: `pnpm typecheck` проходит. Остановите сервер посреди ответа: в базе ответ `stopped`, а `afterTurn` получил
1176
+ `stopReason: "shutdown"`.
1177
+
1178
+ ## 0.25.0
1179
+
1180
+ ### Minor Changes
1181
+
1182
+ - 99d426e: Приветствие по указанию: `greeting: { instruction }` в `createChat`, `openSubjectChat` и ответе `subjectChats`. При
1183
+ создании чата SDK один раз будит агента служебным сообщением `conversation_started`, и агент пишет первым сам. Ровно одно
1184
+ из `text` и `instruction`. Найденный чат не будится; сбой побудки — в `logError`, чат всё равно отдаётся.
1185
+ - d8201f6: Через OpenRouter модели видят картинку в результате инструмента: в профили вписан `openrouter` и запрещены хозяева модели,
1186
+ которые её не видят или отвергают запрос (замер всех хозяев 2026-10-06, `specs/research-openrouter-tool-images.md`).
1187
+
1188
+ - DeepSeek 4.1 Flash: `toolResultImageProviders: ["deepseek", "openrouter"]`; через OpenRouter запрещены `deepinfra`,
1189
+ `open-inference`, `phala`, `sail-research`, `venice`.
1190
+ - GLM 5.3 Flash: `toolResultImageProviders: ["openrouter"]` (раньше отметки не было); запрещены `cloudflare`, `deepinfra`,
1191
+ `open-inference`, `sail-research`, `venice`.
1192
+ - Gemini 3.8 Flash: запрещён `google-vertex` — на картинку из инструмента он отвечал ошибкой или пустым ответом. Остаётся
1193
+ Google AI Studio.
1194
+ - Запрет — в профиле модели (`providerOptions.openrouter.provider.ignore`) и стоит в каждом запросе: запрос одинаков от
1195
+ хода к ходу, кэш модели не теряется.
1196
+ - `createOpenRouterProvider` сливает маршрут из профиля со своими настройками: `maxPrice`, `sort` и т. п. сохраняются
1197
+ (раньше маршрут из опций запроса затирал их целиком). Новая опция `ignore: string[]` — свой запрет хозяев; объединяется
1198
+ с запретом модели.
1199
+
1200
+ Обязательных действий нет, кроме шага 3 ниже. Инструменты, которые смотрят на `toolResultImageProviders` (снимок страницы в
1201
+ `@ykurilov/agent-chat-web-tools`, свои флаги приложения вроде «модель видит картинки инструментов»), теперь включаются у
1202
+ DeepSeek 4.1 Flash и GLM 5.3 Flash через OpenRouter сами.
1203
+
1204
+ ### Как обновить приложение
1205
+
1206
+ 1. `pnpm update "@ykurilov/agent-chat-*" --latest`.
1207
+ 2. Если приложение для OpenRouter описывает картинку словами там, где профиль говорит «не видит», — ничего не делать:
1208
+ решение по `profile.toolResultImageProviders?.includes(providerId)` само станет `true` для DeepSeek и GLM через OpenRouter.
1209
+ 3. Если приложение подменяло профиль модели своим `providerOptions.openrouter` (через `addModels`), сохраните в нём
1210
+ `provider.ignore` из стандартного профиля, иначе запрет пропадёт.
1211
+ 4. Проверить: `pnpm typecheck`; в ходе через OpenRouter с картинкой в результате инструмента модель называет то, что на ней.
1212
+
1213
+ ## 0.24.0
1214
+
1215
+ ### Minor Changes
1216
+
1217
+ - 9b895cd: Ломает приложения: только исчерпывающие `switch` по видам ошибок сервера и событиям чата (новые значения) и код, который
1218
+ сам читает ответ `resolveUser`. Без новых настроек поведение прежнее.
1219
+
1220
+ Агент внутри продукта: агент работает с объектами приложения — уроком, заказом, документом. Всё по желанию; без новых
1221
+ настроек запрос к модели не меняется ни на байт. Подробно — `docs/in-product-agent.md`.
1222
+
1223
+ - **Проверка перед ходом.** `beforeTurn({ userId, chat, action, text? })` отказывает своими словами: 403 `turn_refused`
1224
+ до любых записей и лимитов, на отправке, правке, повторе, переделке, подтверждении, виджете, служебных сообщениях, фоновой
1225
+ задаче и в мессенджерах. `resolveUser` может вернуть `{ refused: { code, message, status? } }` → `access_refused` на всех
1226
+ адресах чата, голоса, голосового режима (WebSocket 4401) и мессенджеров. React: `onTurnRefused`, текст отказа вместо «нет
1227
+ связи», сообщение возвращается в поле. Ручная проверка — `service.assertTurnAllowed`.
1228
+ - **Итог хода.** `afterTurn(turn)` после каждого хода с повторами и сроком (`afterTurnAttempts`, `afterTurnTimeoutMs`),
1229
+ переживает перезапуск сервера. `TurnResult`: владелец и автор хода, текст, части, расход, действие, канал, объект,
1230
+ сотрудник. События приложения `{ type, data? }` — из хука, из инструмента (`model.emitAppEvent`) и вне хода
1231
+ (`service.publishAppEvent`); в React `appEvents` и `onAppEvent`, события переживают перезагрузку. Модели не уходят.
1232
+ - **Чат объекта.** Ключ и данные объекта у чата, один чат на ключ у человека: `service.openSubjectChat`, на сервере
1233
+ `subjectChats` (`POST …/subject`), в React `<AgentChat subject="lesson:42" />`. Данные объекта видят `beforeTurn`,
1234
+ инструменты (`model.subject`) и `afterTurn`; в браузер и модели не уходят.
1235
+ - **Приветствие.** `greeting: { text }` — первое сообщение от агента без вызова модели. Проверено на всех моделях каталога.
1236
+ - **Лимит сообщений.** Окно лимитов с `limitMessages` вместо `limitUsd`, `exemptUsers` — люди без лимитов,
1237
+ `onMessageThreshold` — пороги. Отказ — 429 с `unit: "messages"`.
1238
+ - **Импорт старой переписки.** `service.importChat({ ownerId, chat, messages })` с датами и картинками
1239
+ (`attachments.importFile`), без хода, памяти, расхода и лимитов.
1240
+ - **Сотрудник проверяет чат человека.** Песочница с опцией `people`: админ видит чаты человека только на чтение и
1241
+ проверяет ответы в копии у себя. Память человека в копии только читается, расход — на админа, человек ничего не видит.
1242
+ Инструменты приложения получают `model.staff` и в копии в данные человека не пишут.
1243
+ - **Свои кнопки у поля ввода.** `composerActions` с помощником `{ value, setValue, send, addFiles, busy, blocked, canSend }`.
1244
+ Договор скина v19: `Composer.actions`; скин v18 работает без кнопок, Тандем готов.
1245
+ - **Хранилища.** Миграции применяются сами: SQLite `0015`, PostgreSQL `0014` (ключ объекта, состояние хука итога).
1246
+
1247
+ ### Что может сломаться
1248
+
1249
+ - **Исчерпывающие `switch` по закрытым спискам.** Новые значения: вид ошибки сервера `turn_refused` и `access_refused`
1250
+ (`HandlerErrorKind`), события чата `after-turn-failed` и `app-event` (`ChatEvent`), `unit` у отказа лимита. Добавьте ветки
1251
+ и надписи.
1252
+ - **Список чатов без чатов объектов.** `listChats` и `GET {basePath}` по умолчанию не показывают чаты с ключом объекта.
1253
+ Касается только приложений, которые начнут ими пользоваться; показать — `chatList: { subjectChats: true }` или
1254
+ `listChats(userId, { subjects: "include" })`.
1255
+ - **Свой `resolveUser` в обёртках.** Тип ответа — `ResolvedUser` (`{ id } | { refused } | null`). Код, который сам вызывает
1256
+ `resolveUser` и читает `.id`, сначала проверяет `"refused" in user`.
1257
+ - **Своё хранилище.** Для новых возможностей нужны необязательные методы (`findChatBySubject`, `importChat`,
1258
+ `setAfterTurnState`, `listPendingAfterTurn`); без них эти возможности при включении дают ошибку при старте, остальное
1259
+ работает. `listChats(ownerId, options?)` получил второй параметр — чаты с ключом скрывать по умолчанию.
1260
+ - **Свой `ChatService`** (заглушки в тестах) добавляет новые методы: `openSubjectChat`, `importChat`, `assertTurnAllowed`, `peekHistory`,
1261
+ `publishAppEvent`.
1262
+ - **Своя реализация `SpendingLimits`** может добавить `takeMessage` — без него сообщения не считаются.
1263
+ - **`AgentChat`: `chatId` стал необязательным** — нужен ровно один из `chatId` и `subject`; ни одного или оба — ошибка при
1264
+ рендере. При смене `chatId` чат пересоздаётся: черновик и состояние прошлого чата не переносятся.
1265
+ - **Окна лимитов: `limitUsd` стал необязательным** (у окна сообщений его нет). Код, который читает свои окна, проверяет
1266
+ `limitMessages`.
1267
+ - **Отказ сервера в React** (`ServerRefusal`) получил `kind` и `code`. 401 со словами приложения (`access_refused`) — это
1268
+ отказ, а не «вход истёк».
1269
+ - **«Пустой чат»** для «Нового чата»: чат объекта пустым не считается, обычный чат с одним приветствием — считается.
1270
+ - Новый подпуть `@ykurilov/agent-chat-core/access` — помощники отказа в доступе для своих обработчиков.
1271
+
1272
+ ### Как обновить приложение
1273
+
1274
+ 1. `pnpm up "@ykurilov/agent-chat-*@^0.24.0"`. Миграции базы применяются сами при открытии хранилища.
1275
+ 2. Исчерпывающие `switch` по `HandlerErrorKind` (сервер) и `ChatEvent` (`chatEvents` ядра): найдите их поиском и добавьте
1276
+ ветки `turn_refused`, `access_refused`, `after-turn-failed`, `app-event`. Обязательных надписей не прибавилось: текст
1277
+ отказа приходит с сервера. Свой словарь для лимита сообщений — необязательные `spendingMessagesLeft`,
1278
+ `spendingMessagesExhausted`, `editMessageLimit`.
1279
+
1280
+ Было: `switch (event.type) { case "tasks-changed": … case "notice-added": … default: assertNever(event) }`
1281
+ Стало: те же ветки плюс `case "after-turn-failed": case "app-event": break;`
1282
+ 3. Свой код, который сам зовёт `resolveUser` и читает `.id`: сначала `if (user && "refused" in user) …`.
1283
+ 4. Ничего больше не нужно, если новые возможности не включать. Чтобы включить — `docs/in-product-agent.md`.
1284
+ 5. Свой скин: кнопки у поля появятся, когда `Composer` начнёт рисовать `actions` (договор v19, `docs/skin-v19.md`).
1285
+ 6. Проверка: `pnpm typecheck` без ошибок, чат открывается и отвечает как раньше.
1286
+
1287
+ ## 0.23.0
1288
+
1289
+ No changes in this release.
1290
+
1291
+ ## 0.22.0
1292
+
1293
+ No changes in this release.
1294
+
1295
+ ## 0.21.0
1296
+
1297
+ ### Minor Changes
1298
+
1299
+ - 7c3bcc4: Картинки в файлы приложения и ход, который не ждёт картинку. Всё новое включается опциями: без них `createImageGeneration` работает как раньше.
1300
+
1301
+ - `createImageQueue` — своя очередь картинок для инструментов приложения: заказ отвечает сразу, готовая картинка приходит в `deliver(image, job, ctx)`; `ctx.isCurrent()` и `ctx.signal` — картинка ещё нужна (запись файла делать условной). Задачи лежат в базе (`sqliteImageJobStore` из `@ykurilov/agent-chat-images/sqlite`, `postgresImageJobStore` из `/postgres`), переживают перезапуск сервера, расход пишется ровно один раз. Есть «Повторить» (`retry`), замена прежнего заказа на то же место (`key`), предел за ход, `stop()`.
1302
+ - `createImageGeneration`: `deliver` — картинка приложению, а не во вложения; `mode: "background"` — инструмент отвечает сразу (с `jobStore`, `pollIntervalMs`, `attempts`, `onQueued`); `maxImagesPerTurn` — предел картинок за ход; `start()` и `stop()`.
1303
+ - Ядро: `agent.tools(model)` получает `model.turnId` — номер хода, один на все попытки и запасного провайдера.
1304
+ - Ядро: необязательный `ChatStorage.hasUsageRecord(id)` — есть ли запись расхода (память, SQLite, PostgreSQL). Своё хранилище может его не делать.
1305
+ - Хранилища задач картинок: одна открытая задача на место (`ownerId` + `key`) — уникальный индекс; своё хранилище бросает `ImageJobKeyTakenError`.
1306
+ - Экспорт `DEFAULT_REMOVE_BACKGROUND_PROMPT` — подсказка разделения фона.
1307
+ - Защита денег: срок у каждого обращения к провайдеру, повторное скачивание того же адреса вместо нового заказа, второй шаг не оплачивает первый заново.
1308
+
1309
+ Фоновый режим и очередь с базой: при остановке сервера до закрытия базы вызвать `await images.stop()` (или `imageQueue.stop()`).
1310
+
1311
+ ## 0.20.0
1312
+
1313
+ ### Minor Changes
1314
+
1315
+ - fd16b01: Помощники, фоновые задачи и мессенджеры. Ничего не ломается: без новых настроек всё работает как раньше.
1316
+
1317
+ - `agent.helpers` в `createChatHandler` / `createChatServer`: узкие агенты со своей моделью и инструментами; основной агент зовёт их как `helper_<имя>` и получает только итог. Человек видит все шаги помощника. Подробности: `docs/helpers.md`.
1318
+ - `background` в `createChatHandler` / `createChatServer` принимает объект из нового пакета `@ykurilov/agent-chat-background`; тогда появляются адреса `{base}/:chatId/tasks` (список, отмена, ответ на подтверждение). Помощник с `background: true` без этого пакета — ошибка при запуске.
1319
+ - `createChat(…, { origin })` помечает чат, созданный из мессенджера (`telegram`, `vk`, `max`); пометка видна в списке чатов.
1320
+
1321
+ Что сделать в приложении: ничего обязательного. Хотите помощников — добавьте `agent.helpers`. Хотите фон или мессенджеры — поставьте новые пакеты `@ykurilov/agent-chat-background` и `@ykurilov/agent-chat-messengers` (см. их README).
1322
+
1323
+ ## 0.19.0
1324
+
1325
+ ### Minor Changes
1326
+
1327
+ - fde2297: DeepSeek 4.1 Flash у провайдера DeepSeek видит картинку в результате инструмента (проверено вживую): в профиле появилась
1328
+ отметка `toolResultImageProviders: ["deepseek"]`. Инструменты, которые смотрят на эту отметку (снимок страницы в
1329
+ `@ykurilov/agent-chat-web-tools`), теперь доступны этой паре. Свой инструмент с картинкой — `toModelOutput` с частью
1330
+ `{ type: "file", mediaType: "image/png", data }`, как у снимка страницы.
1331
+
1332
+ Обновление ничего не требует. Живая проверка любой пары модель+провайдер с отметкой — `packages/core/tests/live/tool-result-images.live.test.ts`.
1333
+ - aa0fd51: Гибкие лимиты расходов. Вместо одного бюджета на месяц можно задать любой набор окон сразу: сутки, неделю или месяц по
1334
+ часовому поясу (со своим днём начала недели), «N часов от первого запроса» или на всё время. Область окна — человек, чат или
1335
+ весь сервис. Ход начинается, только если не исчерпано ни одно окно.
1336
+
1337
+ - `createSpendingLimits({ windows: [...] })` вместо `budget` и `period`. Прежний `budget` работает как раньше (и получил
1338
+ `period: "week"`).
1339
+ - `carryOverrun: true` у окна: перерасход начатого хода переносится в начало следующего окна.
1340
+ - `turn: { minRemainingUsd, maxCostUsd, maxToolCalls, overrun }` — запас на старт хода и потолки одного хода. На потолке
1341
+ ход заканчивается итогом без новых вызовов инструментов.
1342
+ - Человеку: свой лимит окна, блокировка (`setUserSettings`, `getUserSettings`) и ручной сброс окна (`resetWindow`).
1343
+ - `onThreshold(event)` — событие при 80% и 100% лимита окна (пороги — `thresholds`), одно на порог в окне, проверка после
1344
+ каждой записи расхода.
1345
+ - `getSpending` отдаёт доллары по каждому окну (`windows`) и `blocked`.
1346
+ - Сервер: в ответе 429 `spending_limit_reached` появились `reason` (`limit` | `blocked` | `reserve`), `windowId` и `hours`.
1347
+ `GET …/spending` отдаёт `reason`, `windowId`, `hours`, `blocked` и список окон `windows` — только доли и время сброса,
1348
+ без долларов.
1349
+ - Браузер: надписи «на неделю», «на 5 ч» (с временем обновления), «сервиса», «Лимита … не хватит на новый ответ», «Новые
1350
+ ответы для вас приостановлены». В `SpendingInfo` новые необязательные поля `hours`, `reason`, `windowId`, `windows`.
1351
+
1352
+ Скользящим окнам, личным настройкам, сбросу и порогам нужны методы состояния хранилища: в памяти, SQLite и PostgreSQL они есть
1353
+ (запись `storage-limit-state`). Подробно — README пакета `@ykurilov/agent-chat-limits`, раздел «Окна лимитов».
1354
+
1355
+ **Ломает приложения:** только исчерпывающие проверки и свои словари по периоду. У `SpendingScope` появилось значение
1356
+ `service`, у `SpendingPeriod` — `week` и `rolling` (в `@ykurilov/agent-chat-core` и `@ykurilov/agent-chat-react`).
1357
+
1358
+ ### Как обновить приложение
1359
+
1360
+ 1. Поищите в приложении `SpendingPeriod`, `SpendingScope`, `info.period`, `info.scope` и `spendingWarning` / `spendingExhausted`
1361
+ в своём словаре надписей. Если надпись строится по таблице периодов, добавьте новые значения:
1362
+
1363
+ Было:
1364
+
1365
+ ```ts
1366
+ const periodPhrase = { month: " на месяц", day: " на день", none: "" };
1367
+ ```
1368
+
1369
+ Стало:
1370
+
1371
+ ```ts
1372
+ const periodPhrase = { month: " на месяц", week: " на неделю", day: " на день", none: "" };
1373
+ const phrase = info.period === "rolling" ? ` на ${info.hours} ч` : periodPhrase[info.period];
1374
+ ```
1375
+
1376
+ То же для области: `info.scope === "service"` — лимит всего сервиса.
1377
+ 2. Свой словарь с `spendingExhausted`: учтите `info.reason` — `"blocked"` (человек заблокирован, `resetsAt` нет) и `"reserve"`
1378
+ (остаток меньше запаса на старт хода).
1379
+ 3. Свой разбор ответа 429 или `GET …/spending` на сервере или в браузере: новые поля необязательные, старые на месте.
1380
+ 4. Ничего из этого нет в приложении — обновление ничего не требует.
1381
+ 5. Проверка: `pnpm typecheck` проходит; с исчерпанным лимитом чат показывает надпись и не даёт отправить сообщение.
1382
+ - 9f94dbe: OpenRouter: маршрутизация из настроек подключения. `createOpenRouterProvider` принимает `maxPrice` (потолок цены за миллион
1383
+ токенов, за запрос и за картинку), `sort` (`throughput` по умолчанию, `price`, `latency` или `false`) и `stickyProvider`
1384
+ (по умолчанию `true`: чат закреплён за одним хозяином модели заголовком `x-session-id`, кэш не теряется). Настройки
1385
+ переживают переподключение через прокси.
1386
+
1387
+ Обновление ничего не требует: без новых настроек запрос прежний, побайтно. Потолок цены — так:
1388
+
1389
+ ```ts
1390
+ extendCatalog(standardCatalog, {
1391
+ addProviders: [createOpenRouterProvider({ maxPrice: { completionPerMillion: 1.3 } })],
1392
+ });
1393
+ ```
1394
+ - 2013f15: Цена модели теперь может зависеть от времени суток: в профиле появились часы пик (`pricing.peakHours`) — окна по дням недели
1395
+ и часам со своим множителем, отдельный множитель для попаданий в кэш и даты-исключения. Каждый шаг хода считается по времени
1396
+ своего запроса. DeepSeek 4.1 Flash у провайдера DeepSeek больше не считается всегда по пиковой цене: вне пика 0,15 / 0,60 / 0,003 $
1397
+ за миллион токенов, по будням 01–04 и 06–10 UTC вдвое. Расход этой пары в лимитах в непиковые часы стал вдвое меньше — как в счёте DeepSeek.
1398
+
1399
+ `costOfUsage(usage, price, at?)` принимает третьим параметром время запроса: без него — цена вне пика.
1400
+
1401
+ Обновление ничего не требует. Своя модель с часами пик — по образцу:
1402
+
1403
+ ```ts
1404
+ pricing: {
1405
+ myprovider: {
1406
+ inputPerMillion: 0.15, outputPerMillion: 0.6, cachedInputPerMillion: 0.003, // цены вне пика
1407
+ peakHours: { timeZone: "UTC", multiplier: 2, windows: [{ days: [1, 2, 3, 4, 5], from: "01:00", to: "04:00" }] },
1408
+ },
1409
+ },
1410
+ ```
1411
+ - aa0fd51: Хранилища держат состояние лимитов расходов: начало скользящего окна, перенос перерасхода, ручной сброс, личные лимиты и
1412
+ блокировку человека, отметки о порогах. Новая таблица `spending_limit_states` (миграция SQLite `0013`, PostgreSQL `0012`
1413
+ применяется сама при открытии хранилища). Запись по версии: из двух одновременных записей с одной версией проходит одна,
1414
+ поэтому две копии сервера не затрут друг друга. В `ChatStorage` — необязательные `getSpendingLimitState(key)` и
1415
+ `saveSpendingLimitState(key, value, expectedVersion)`, тип `SpendingLimitStateRecord`.
1416
+
1417
+ Обновление ничего не требует. Своё хранилище работает как раньше. Если с ним нужны окна «N часов», личные настройки, сброс
1418
+ или пороги из `@ykurilov/agent-chat-limits`, реализуйте эти два метода и проверьте общими тестами
1419
+ `describeChatStorageContract(createStorage, { spendingLimitState: "required" })`.
1420
+ - 05e258b: Сервер сообщает приложению, что ход агента закончен: необязательный `onTurnFinished` в настройках `createChatHandler`
1421
+ (и `createChatService`) получает `{ userId, chatId, messageId, status, modelId, providerId }` один раз на ход при любом
1422
+ итоге — законченном, остановленном, с ошибкой, прерванном или ждущем человека. Итог к этому мигу уже записан. Ход
1423
+ обработчик не ждёт; если обработчик бросил или его обещание отклонено, ответ не ломается, сбой уходит в `logError`.
1424
+ Тип — `TurnFinished` из `@ykurilov/agent-chat-core`. Без `onTurnFinished` всё как раньше.
1425
+ - aa0fd51: Потолки одного хода. У `SpendingLimits` два новых необязательных метода: `turnRules({ userId, chatId })` возвращает
1426
+ `{ maxCostUsd?, maxToolCalls? }` перед ходом, `afterSpending({ userId, chatId })` зовётся после записи расхода хода. Дошёл ход
1427
+ до потолка денег (по ценам профиля модели) или вызовов инструментов — следующий шаг идёт без инструментов и с просьбой
1428
+ подвести итог, ответ кончается итогом, а не обрывом. Шаг, начатый до потолка, доделывается. Ошибка `afterSpending` уходит
1429
+ в `logError` и ход не задевает. Тип `TurnSpendingRules`. Пакет `@ykurilov/agent-chat-limits` задаёт оба метода сам
1430
+ (`turn` в `createSpendingLimits`).
1431
+
1432
+ Обновление ничего не требует. Свои лимиты без этих методов работают как раньше.
1433
+
1434
+ ## 0.18.6
1435
+
1436
+ No changes in this release.
1437
+
1438
+ ## 0.18.5
1439
+
1440
+ No changes in this release.
1441
+
1442
+ ## 0.18.4
1443
+
1444
+ No changes in this release.
1445
+
1446
+ ## 0.18.3
1447
+
1448
+ No changes in this release.
1449
+
1450
+ ## 0.18.2
1451
+
1452
+ No changes in this release.
1453
+
1454
+ ## 0.18.1
1455
+
1456
+ No changes in this release.
1457
+
1458
+ ## 0.18.0
1459
+
1460
+ ### Minor Changes
1461
+
1462
+ - 43d7ebe: Песочница агента для админа: новый пакет `@ykurilov/agent-chat-sandbox`. Админ говорит с боевым агентом и видит журнал:
1463
+ каждое обращение к модели (что ушло, что пришло, токены, кэш, цена) и каждый вызов инструмента. Любое сообщение можно
1464
+ поправить (текст и результаты обычных инструментов), правка создаёт ветку. Модель и системная инструкция меняются для одного
1465
+ чата, боевые не трогаются. Память в песочнице только на чтение.
1466
+
1467
+ - сервер: `createSandboxHandler({ chat, isAdmin, journal })`, журнал `createSqliteSandboxJournal` (`/sqlite`) или
1468
+ `createPostgresSandboxJournal` (`/postgres`, схема `agent_chat`), хранится 30 дней; не админ получает 404 на любом адресе;
1469
+ - страница: `AgentSandbox` из `@ykurilov/agent-chat-sandbox/react`, готовый скин `tandemSandboxSkin`;
1470
+ - `createChatServer` — новый экспорт server (на нём стоят и боевой, и песочный обработчик);
1471
+ - core: `turnObserver`, `chatInstructions`, `lockedToolOutputs`, `editAnswer`; боевой запрос к модели остаётся прежним побайтно;
1472
+ - договор скина чата версии 15: необязательные `AgentMessage.onEdit/edited` и `ToolCallRow.outputEdited`; новые надписи
1473
+ `answerEdited`, `toolOutputEdited`, `editAnswer`; договор скина песочницы `SandboxSkin` версии 1.
1474
+
1475
+ Подробно: `docs/sandbox.md`.
1476
+
1477
+ ### Как обновить приложение
1478
+
1479
+ Обычному приложению делать ничего не нужно: песочница включается отдельно, миграций боевой базы нет (таблицы `sandbox_*` создаёт
1480
+ сам журнал).
1481
+
1482
+ 1. Полный словарь надписей, написанный вручную без `{ ...russianLabels }`, — добавьте `answerEdited`, `toolOutputEdited`, `editAnswer`.
1483
+ 2. Свой скин версии 14 работает без правок; чтобы показать «изменено» и кнопку правки, реализуйте необязательные части версии 15.
1484
+ 3. Своя реализация сервера поверх `createChatHandler` продолжает работать; для песочницы возьмите `createChatServer`.
1485
+ 4. Проверка: `pnpm typecheck`, затем в чате отправить сообщение и получить ответ.
1486
+
1487
+ ## 0.17.0
1488
+
1489
+ No changes in this release.
1490
+
1491
+ ## 0.16.0
1492
+
1493
+ ### Minor Changes
1494
+
1495
+ - cf52f10: **Ломает только исчерпывающие проверки:** у `UsageSource` появилось значение `images`, у видов ошибок сервера — `widget_not_pending` и `invalid_answer`, у `ChatService` — метод `answerWidget`. Своё хранилище должно поддержать условную запись `AwaitingMessagePatch.expectedParts` (при несовпадении частей — `MessageNotAwaitingError`; помощник `sameMessageParts` из core), иначе две копии сервера могут затереть ответы друг друга; общие тесты хранилищ это проверяют. Остальное необязательно, приложение без правок работает как раньше.
1496
+
1497
+ Что нового: агент может спросить кнопками, звёздами или формой (виджеты), нарисовать схему блоком ` ```mermaid ` и создать картинки через Runware. Всё включается отдельно:
1498
+
1499
+ - виджеты: `agent: { widgets: { basic: true, custom: [свойВиджет] } }`, свой виджет — `defineWidget` из core и компонент в `widgets={{ … }}` у `AgentChat`; ответ человека идёт на `POST /:chatId/widgets/:toolCallId`, ход ждёт со статусом `awaiting_approval`;
1500
+ - схемы: новый пакет `@ykurilov/agent-chat-diagrams`, `agent: { diagrams: true }` на сервере и `diagrams={diagramsClient}` на странице;
1501
+ - картинки: новый пакет `@ykurilov/agent-chat-images` (нужен пакет вложений и `RUNWARE_API_KEY`), `images.tools(model)` в `agent.tools`, `images={imagesClient}` на странице; свой срок ожидания (210 с) инструмент сообщает ядру сам (`declareToolTimeout`), расход бронируется до заказа (`assertWithinLimit({ pendingUsd })`);
1502
+ - окно просмотра картинок и схем: Тандем готов, своему скину — необязательные части договора версии 13 (`Widget`, `SuggestedReplies`, `GeneratedImages`, `MediaViewer`, `diagramColors`, `renderDiagram`). `splitMermaidBlocks` экспортируется из react;
1503
+ - вложения: `Attachments.storage` и `detectFileKind` экспортируются из пакета вложений;
1504
+ - голосовой режим произносит вопрос и варианты ждущего виджета и говорит «схема на экране» вместо блока схемы;
1505
+ - «Стоп» на экране закрывает висящие вызовы так же, как сервер, разрешённые без результата — «результат неизвестен» (`stoppedToolCallReasons.resultUnknown`);
1506
+ - упавший свой виджет страницы заменяется запасным видом скина, лента цела.
1507
+
1508
+ Подробно: `docs/widgets.md`, `docs/diagrams.md`, `docs/images.md`, `docs/media-viewer.md`.
1509
+
1510
+ ### Как обновить приложение
1511
+
1512
+ Обычному приложению на SQLite или PostgreSQL из набора делать ничего не нужно: миграций нет, всё новое включается отдельно.
1513
+
1514
+ 1. Исчерпывающий `switch` по `UsageSource` — добавьте ветку `"images"`; по видам ошибок сервера — `"widget_not_pending"` и `"invalid_answer"`.
1515
+ 2. Своя реализация `ChatService` — добавьте метод `answerWidget({ chatId, ownerId, toolCallId, answer, page }): Promise<{ resumed: boolean }>` (тип `WidgetAnswerRequest` из core, образец — служба из core).
1516
+ 3. Своё хранилище: `updateAwaitingMessage` получает необязательное поле `expectedParts`. Если оно есть и части сообщения в базе с ним не совпадают (`sameMessageParts` из core), бросайте `MessageNotAwaitingError`; проверку и запись делайте в одной транзакции. Прогоните общие тесты хранилищ из core.
1517
+ 4. Своя обёртка `assertWithinLimit` из limits — пробрасывайте новое необязательное поле `pendingUsd`.
1518
+ 5. Проверка: `pnpm typecheck`, затем в чате отправить сообщение и получить ответ.
1519
+
1520
+ ## 0.15.0
1521
+
1522
+ ### Minor Changes
1523
+
1524
+ - f05ad23: Часовой пояс для лимитов расходов: `createSpendingLimits({ timeZone: "Europe/Moscow" })`. Сутки и месяцы режутся
1525
+ по полуночи этого пояса, а не по UTC (раньше московский суточный лимит обнулялся в 03:00). Переход на летнее время
1526
+ учтён: сутки бывают 23 и 25 часов. Для сегодняшней даты агента этот пояс только запасной: главнее пояс браузера
1527
+ человека, а пояс лимитов берётся, когда браузер свой пояс не прислал. Дата обновления лимита на экране — по поясу лимитов. Пояс не из базы IANA — ошибка при старте.
1528
+
1529
+ Новое: `timeZone` в настройках лимитов и необязательное поле `timeZone` у `SpendingLimits`, `SpendingStatus`,
1530
+ `SpendingLimitReachedError`, `SpendingReport` и `SpendingInfo` (экран); ответы `GET …/spending` и отказ 429 получают
1531
+ `timeZone`, если пояс задан. Ядро экспортирует `resolveTimeZone`.
1532
+
1533
+ ### Как обновить приложение
1534
+
1535
+ Ничего делать не нужно: без `timeZone` лимиты по UTC, дата агента по поясу человека или сервера, как раньше.
1536
+
1537
+ Чтобы считать сутки по Москве, в файле, где создаются лимиты:
1538
+
1539
+ ```ts
1540
+ const limits = createSpendingLimits({ storage, catalog, budget, period: "day", timeZone: "Europe/Moscow" });
1541
+ ```
1542
+
1543
+ `agent.timeZone` после этого задавать не нужно. Если он уже задан, он должен совпадать с поясом лимитов (другое имя того же пояса
1544
+ вроде `Asia/Calcutta` и `Asia/Kolkata` — совпадение), иначе
1545
+ служба чата не создастся (ошибка называет оба пояса). Свои надписи `spendingExhausted`, которые сами форматируют
1546
+ `resetsAt`, могут брать пояс из `info.timeZone`. Проверка: `limits.getSpending({ userId })` отдаёт `periodStart`
1547
+ вида `…T21:00:00.000Z` (полночь по Москве).
1548
+ - f05ad23: Встроенные провайдеры (OpenRouter, DeepSeek, Fireworks, Together) можно подключить через прокси: свой адрес `baseURL`
1549
+ и заголовки `headers` к каждому запросу. Нужно серверу в России, откуда зарубежные провайдеры напрямую недоступны.
1550
+ Через прокси идёт всё: ход агента, фоновые вызовы памяти и сжатия, сверка расхода OpenRouter. Меню моделей, цены и учёт
1551
+ расходов те же. Значения заголовков вычищаются из ошибок и журналов, как ключи.
1552
+
1553
+ Новое: правка каталога `extendCatalog(…, { connectProviders: { openrouter: { baseURL, headers } } })` (провайдер остаётся
1554
+ на своём месте, с прежними настройками), фабрики `createDeepSeekProvider`, `createFireworksProvider`,
1555
+ `createTogetherProvider`, у `createOpenRouterProvider` — `baseURL` и `headers`, тип `ProviderConnection`, поля
1556
+ `connection` и `connect` у `ProviderDescription` (необязательные). Голос: `headers` у чистки текста
1557
+ (`polish: { provider: "together", baseUrl, headers }`) и у звуков человека (`sounds: { baseUrl, headers }`).
1558
+
1559
+ ### Как обновить приложение
1560
+
1561
+ Ничего делать не нужно: без новых настроек запросы идут прямо к провайдерам, как раньше.
1562
+
1563
+ Чтобы пустить провайдера через прокси, в файле, где собирается каталог моделей (обычно рядом с `createChatHandler`):
1564
+
1565
+ ```ts
1566
+ import { extendCatalog, standardCatalog } from "@ykurilov/agent-chat-core";
1567
+
1568
+ const catalog = extendCatalog(standardCatalog, {
1569
+ connectProviders: {
1570
+ openrouter: {
1571
+ baseURL: "https://fr-api-route.yeycourses.com/openrouter/api/v1",
1572
+ headers: { "X-Proxy-Token": process.env.PROXY_TOKEN! },
1573
+ },
1574
+ },
1575
+ });
1576
+
1577
+ createChatHandler({ /* … */ catalog });
1578
+ ```
1579
+
1580
+ Токен прокси — только в окружении сервера (`PROXY_TOKEN`), не в коде. Не задан `PROXY_TOKEN` — ошибка при старте
1581
+ (у заголовка нет значения). Значение заголовка короче 8 знаков и заголовок `Authorization` — тоже ошибка при старте.
1582
+ Прокси должен пробрасывать заголовок ответа `x-generation-id`, иначе расход OpenRouter останется оценкой. Проверка:
1583
+ один ответ в чате приходит, в журнале прокси виден запрос.
1584
+
1585
+ ## 0.14.0
1586
+
1587
+ ### Minor Changes
1588
+
1589
+ - 4f33e85: Браузерные инструменты: агент делает действие на странице человека — например, открывает карточку задачи. Приложение описывает их общим модулем через `tool({ description, inputSchema })` без `execute` и подключает на сервере `agent.browserTools`, а на странице — функции с теми же именами в `browserTools` у `AgentPanelProvider`. Когда модель зовёт такой инструмент, сервер шлёт временный кусок `data-browser-call` той вкладке, с которой пришёл ход, и ждёт ответа `POST {base}/:chatId/browser-tools/:toolCallId` (`{ tabId, output }` или `{ tabId, error }`, `204`; тело больше 64 КБ — `400 invalid`, дальше не читается). Результат уходит модели, ошибка — строкой «ошибка» с её текстом, ход продолжается. Срок ответа — `agent.browserToolTimeoutMs` (по умолчанию 30 с, от 1 с до 2³¹−1 мс), после него — «Страница не ответила за N с»; «стоп» и удаление чата снимают ожидание. Без вкладки — «Действие на экране недоступно: страница не подключена». Результат больше 16 КБ — «Слишком большой результат», ошибка обрезается до 1000 знаков, пустой результат — «готово». «Спросить разрешения» работает как у обычных инструментов: функция зовётся только после «Разрешить». Ответ чужой вкладки, повторный и поздний — `409 not_pending` (`BrowserCallNotPendingError` в ядре, новый вид `not_pending` у `HandlerErrorKind`). Описание с `execute` или имя, совпавшее с инструментом приложения или памяти, — ошибка при создании службы. Вкладка исполняет каждый вызов чата один раз, в том числе после перезагрузки. Ожидание живёт в памяти процесса: как и продолжение ответа, работает на одном сервере.
1590
+
1591
+ **Ломает приложения** только там, где сами реализуют интерфейс `ChatService` (например, поддельная служба в тестах): в него добавился метод `answerBrowserCall`.
1592
+ - 15fbbcc: Инструмент агента сообщает странице, что изменил данные. В ядре у `AgentToolsModel` новая функция `notifyDataChanged({ tag, details? })`: её зовут после записи, и сообщение уходит браузеру той вкладки, где идёт ответ, сразу, не дожидаясь конца вызова. Метка — 1–100 знаков, подробности — JSON до 4 КБ; неверная форма — `TypeError` внутри инструмента. После конца хода вызов ничего не делает и пишет в журнал сервера. В хранилище сообщение не попадает. В слое поведения `useAgentChat` с мостом передаёт такие сообщения приложению: на странице их ловят `useAgentDataChanges(tag, handler)` и `onDataChanged` провайдера, повтор одного и того же сообщения подписчикам не раздаётся, при закрытой панели сообщение тоже доходит.
1593
+
1594
+ **Ломает приложения** только там, где сами собирают `AgentToolsModel` (например, в тестах инструментов): в объект нужно добавить `notifyDataChanged`. Инструментам, которые читают `profile`, `chatId`, `ownerId` или `place`, ничего делать не нужно.
1595
+ - c09c1e2: Основа боковой панели агента. В ядре — типы `ScreenPlace` (где человек в приложении), `DataChange` (инструмент изменил данные), `TurnRequestContext` (с чем пришёл запрос хода) и имена временных кусков потока `data-app-change`, `data-browser-call`. В слое поведения — тип `AgentAppBridge` (связь чата с приложением), необязательные `appBridge` и `initialDraft` у `AgentChat` и `useAgentChat` (`initialDraft` кладёт текст в поле ввода при открытии чата), `appBridge` у `ChatWorkspace`, `rememberKey` у `useChatList` (свой ключ запомненного чата), новый вход `@ykurilov/agent-chat-react/panel` и договор скина версии 12 — отдельный `AgentPanelSkin` (`ChatSkin`, `ChatListSkin`, `MemorySkin` не менялись).
1596
+
1597
+ **Ломает приложения** только с полным словарём надписей `ChatLabels`, написанным вручную: в него добавились надписи `agentPanel…` (`agentPanelLauncher`, `agentPanelOpen`, `agentPanelClose`, `agentPanelLabel`, `agentPanelChats`, `agentPanelBackToChat`, `agentPanelBusy`). Тем, кто пишет `{ ...russianLabels, … }`, ничего делать не нужно. Без новых свойств чат работает как раньше.
1598
+ - 062ce36: Человек, чат и место на экране для инструментов агента. Функция `agent.tools(model)` получает в `model` не только `ownerId` и `chatId`, но и `place` — где сейчас человек в приложении (`{ description, data? }` или `null`); функция зовётся раз на ход, поэтому два человека одновременно не путаются. Место уходит с каждым запросом хода (отправка, правка, повтор, переделка, ответ на подтверждение) полями `place` и `tabId` в теле, а модель видит его отдельной частью в самом конце последнего сообщения человека (после пометки режима и блока памяти). В историю чата и в сжатие место не пишется, инструкция и описания инструментов не меняются, поэтому кэш модели цел. Описание длиннее `agent.placeMaxLength` (по умолчанию 1000, не меньше 100) обрезается с «…», данные больше 4 КБ отбрасываются, обе обрезки пишутся в журнал сервера без текста. Неверная форма `place` или `tabId` — `400 invalid`, ничего не записано. В службе чата у `sendMessage` и `editMessage` в `options` появилось `page`, у `retry`, `regenerate` и `answerApproval` — последний аргумент `page` (`TurnRequestContext`). `useAgentChat` с `appBridge` кладёт `tabId` и текущее место в тела всех пяти запросов; без моста тела прежние. Старые приложения работают без изменений. **Ломает** только код, который сам собирает объект `AgentToolsModel` (например, тесты инструментов): в него добавилось обязательное поле `place` (`null`, если места нет); функции `tools(model)` приложений ничего менять не нужно.
1599
+ - 656233a: Долгая память агента. Агент сам запоминает устойчивое о человеке (предпочтения, правила общения, факты о работе) и помнит это в новых чатах. В новом пакете `@ykurilov/agent-chat-memory` лежат инструмент `manage_memory`, правила записей и блок памяти для запроса. Человек видит записи на экране памяти: может добавить, исправить, удалить запись или стереть всю память. Записи у каждого человека свои, удаление чата их не трогает. Раз в сутки после ответа та же модель предлагает слить повторные записи (опция `optimizer`); код проверяет группы и применяет годные, на экране появляется раздел «Объединено» с кнопкой «Вернуть как было». Отрицание не теряется («не любит кофе» не склеивается с «любит кофе»). После сбоев проверка ждёт дольше: час, 6 часов, сутки. Пределы записей и дубли держатся при одновременных действиях (замок на человека, а в SQLite и PostgreSQL ещё и проверка внутри транзакции), в том числе при возврате склейки. Память не меняет начало запроса к модели, поэтому кэш модели сохраняется.
1600
+
1601
+ **Ломает приложения** только с полным словарём надписей `ChatLabels`, написанным вручную (шаг 5): в него добавились надписи `memory…`. Остальным обновление ничего не ломает: без опции `memory` чат работает как раньше. Чтобы включить память, выполните шаги ниже. Новые адреса: `/api/chat/memory/merges` и `/api/chat/memory/merges/:runId/revert`. Миграции SQLite `0009` и `0010` и PostgreSQL `0008` и `0009` применяются сами при открытии базы (`0010` и `0009` — задел под склейку повторов записей).
1602
+
1603
+ ### Как обновить приложение
1604
+
1605
+ 1. **Включить память на сервере** (по желанию). Пакет ставится отдельно: `pnpm add @ykurilov/agent-chat-memory`. В файле, где создаётся `createChatHandler`:
1606
+
1607
+ Было:
1608
+
1609
+ ```ts
1610
+ createChatHandler({ basePath: "/api/chat", storage, resolveUser /* … */ });
1611
+ ```
1612
+
1613
+ Стало:
1614
+
1615
+ ```ts
1616
+ import { createLongTermMemory } from "@ykurilov/agent-chat-memory";
1617
+
1618
+ createChatHandler({ basePath: "/api/chat", storage, resolveUser, memory: createLongTermMemory({ storage }) /* … */ });
1619
+ ```
1620
+
1621
+ Инструкция агента и инструмент `manage_memory` добавляются сами: в `agent.instructions` и `agent.tools` ничего дописывать не нужно. Имя `manage_memory` занято: своего инструмента с таким именем быть не должно (иначе ошибка при создании). Адреса `/api/chat/memory` появляются сами. Без `memory` они отвечают `404`.
1622
+ 2. **Подключить экран памяти** (по желанию). Найдите страницу, куда его поставить, и добавьте компонент; `createMemoryApi` вызывайте один раз (например, в `useMemo`):
1623
+
1624
+ ```tsx
1625
+ import { AgentMemory, createMemoryApi } from "@ykurilov/agent-chat-react";
1626
+ import { tandemMemorySkin } from "@ykurilov/agent-chat-skin-tandem";
1627
+
1628
+ const memoryApi = useMemo(() => createMemoryApi({ api: "/api/chat" }), []);
1629
+ <AgentMemory api={memoryApi} skin={tandemMemorySkin} />
1630
+ ```
1631
+ 3. **Подпись вызова в ленте.** Было: `labels={russianLabels}`. Стало: `labels={withMemoryToolTitles(russianLabels)}`, где `withMemoryToolTitles` — из `@ykurilov/agent-chat-memory/labels`. Вместо `manage_memory` в ленте будет «Работа с памятью».
1632
+ 4. **Свой скин (договор скина версии 11).** `ChatSkin` и `ChatListSkin` не менялись. Экрану памяти нужен отдельный `MemorySkin` из пяти компонентов (`MemoryScreen`, `MemoryGroup`, `MemoryNoteRow`, `MemoryNoteEditor`, `MemoryConfirm`). Скин Тандема даёт его готовым: `tandemMemorySkin`. Описание — `specs/017-long-term-memory/contracts/memory.md`, раздел 5.
1633
+ 5. **Полный словарь надписей.** Если приложение задаёт объект `ChatLabels` целиком, а не `{ ...russianLabels, … }`, добавьте новые надписи: все ключи, которые начинаются с `memory` (`memoryHeading`, `memoryAdd`, `memoryClearAll` и другие, полный список — тип `ChatLabels` в `@ykurilov/agent-chat-react`). Без этого `pnpm typecheck` не пройдёт.
1634
+ 6. **Своё хранилище.** Нужно только при включённой памяти. В своей реализации `ChatStorage` добавьте пять необязательных методов: `listMemoryNotes`, `insertMemoryNotes`, `updateMemoryNote`, `deleteMemoryNote`, `clearMemoryNotes`. Сигнатуры — в `ChatStorage` пакета core и в `packages/storage-sqlite/src/sqlite-storage.ts`. Без них `createLongTermMemory` остановится с ошибкой `MemoryStorageNotSupportedError` и списком недостающих методов. Для склейки повторов нужны ещё шесть методов (`claimMemoryOptimizationRun`, `finishMemoryOptimizationRun`, `applyMemoryMerge`, `revertMemoryMerge`, `listMemoryOptimizationRuns`, `listMergedMemoryNotes`); без них склейка выключена. Чтобы пределы держались при нескольких серверах, добавьте необязательные `insertMemoryNotesChecked` и `updateMemoryNoteChecked`. Общие тесты `describeChatStorageContract` проверят их сами.
1635
+ 7. **Проверка.** `pnpm typecheck` и сборка проходят. Напишите агенту «Запомни, что я пишу на TypeScript», откройте экран памяти: там запись в разделе, который выбрал агент. В новом чате спросите, что он про вас знает.
1636
+ - 96098be: Мелкие исправления (022). Символ с кодом ноль в первом сообщении больше не роняет отправку на Postgres: он выбрасывается из названия чата, из записей памяти и из сводки сжатия истории (иначе запись сжатия падала бы при каждой попытке); в самом тексте сообщения он лежит как раньше. Вложения отмечаются прикреплёнными до записи сообщения, а не после: если сервер упал между двумя шагами, чистка брошенных файлов не удалит их через сутки (файл, отмеченный зря, безвреден и уходит вместе с чатом). Во всех пакетах появилось поле `engines: { node: ">=22" }`; голосовой сервер (`createVoiceRelay`) и голосовой диалог (`createDialogRelay`) на Node 20 сразу при создании говорят понятной ошибкой, что нужен Node 22 (раньше падали при первом соединении; со своим `connectUpstream` проверки нет). В пакете вложений `react` и `@ykurilov/agent-chat-react` стали необязательными peer-зависимостями: серверу без экрана они больше не ставятся. Приложению, которое берёт `@ykurilov/agent-chat-attachments/react`, достаточно иметь их в проекте — для чата на экране они нужны и так; если они подтягивались только через вложения, добавьте их в зависимости приложения.
1637
+ - 278a718: Обрыв связи с моделью посреди ответа больше не заканчивается ошибкой (022, этап 6а). Если провайдер перегружен (`overloaded`), упёрся в лимит (`rate_limited`) или замолчал на 120 с после начала ответа, ход ждёт около 2 с и продолжает тот же ответ у того же провайдера. Модель видит написанное и пометку о сбое последним сообщением с дословным хвостом оборванного текста и дописывает с места обрыва. Если модель всё же потеряла перенос строки между пунктами списка или строками таблицы, он подставляется. Недописанные вызовы инструментов закрываются ошибкой, в ответе не остаётся вечного «выполняется». Выполненные инструменты не вызываются заново. Человек видит один ответ без плашки ошибки и без «Повторить», подписчик, подключившийся во время паузы, получает ответ с начала. Продолжений не больше двух на ход (паузы 2 и 5 с с разбросом; `retry-after` провайдера только удлиняет паузу, но не дольше 10 с). Третий обрыв — ошибка, как раньше. Ответ с запросом подтверждения не продолжается. Запасной провайдер после начала ответа не используется. Начало запроса продолжения побайтно совпадает с оборванным, кэш не теряется и ложного `[промах кэша]` нет. Расход хода — сумма попыток, одна запись. Журнал сервера: `[автопродолжение] … попытка=N из 2 пауза=…мс причина=… знаков написано=…`. «Стоп» во время паузы останавливает ход. Приложению ничего менять не нужно.
1638
+ - c1581f4: Журнал промахов кэша и привязка OpenRouter к одному серверу (022). Когда начало запроса то же, что в прошлом запросе чата, а провайдер почти ничего не взял из кэша (например, в инструкцию вписано текущее время), сервер пишет в журнал одну строку: `[промах кэша] чат=… модель=… провайдер=… токенов=20000 переплата≈$0.0540 пауза=1.5мин`. Токены — сколько входа прошло мимо кэша; переплата считается по ценам модели (нет цены кэша — `переплата=неизвестна`); пауза — время с прошлого шага. Текстов сообщений и ключей в строке нет. Промахи до 1024 токенов не считаются; первый запрос чата, другая ветка (правка, «Переделать»), сжатая история, другая модель или провайдер, а также провайдер, ни разу не сообщавший о кэше, строки не дают. Включено по умолчанию, выключается `AgentSettings.cacheMissLog: false`. Следилка держит в памяти процесса последние 500 чатов. Хвост запроса (блок памяти и место на экране), которого на следующем ходе в начале запроса уже нет, из промаха вычитается (оценка: знаки / 3).
1639
+
1640
+ Кэш и хвост запроса: у провайдеров с явной меткой кэша (OpenRouter) метка стояла только на конце последнего сообщения, то есть после блока памяти и места на экране. На следующем ходе этих блоков в начале запроса нет, префикс не находился, и кэш откатывался к инструкции: вся история пересчитывалась каждый ход. Теперь та же метка стоит и на последней части вопроса перед хвостом; запрос побайтно стабилен. У провайдеров без явных меток (DeepSeek, Fireworks, Together) кэш префиксный и менять нечего.
1641
+
1642
+ OpenRouter получает заголовок `x-session-id` с номером чата: запросы одного чата идут на один сервер, кэш не теряется при переходе между серверами. Заголовок безопасен: обычный номер (латиница, цифры, `._:~-`, до 128 знаков, например UUID) идёт как есть, кириллица, пробелы и слишком длинные номера заменяются стабильной короткой свёрткой; то же у `x-session-affinity` Fireworks. Приватность: номер чата уходит в OpenRouter как идентификатор сессии.
1643
+ - 213bcb3: Переписка не влезла в окно модели — ход сам сжимает историю и продолжает тот же ответ (022, этап 6). Раньше человек получал ошибку. Теперь ядро один раз сжимает историю чата до текущего вопроса, не глядя на порог и паузу после неудачного сжатия. Потом ход продолжает тот же ответ с уже выполненными шагами: инструменты второй раз не запускаются, повторного начала ответа клиент не видит. Если сжатие чата уже идёт, ход ждёт его и, если оно оставило несжатое до вопроса, один раз дожимает; «Стоп» обрывает ожидание сразу. Если в окно не влез уже сам длинный ход, первые его шаги пересказываются, а последние один-два идут как есть. Пересказ живёт только в запросах хода, в базе и на экране ответ целиком. Не помогло и это — ответ `error` с видом `context_overflow`. Если сам запрос сжатия не влез, он повторяется один раз: длинные результаты инструментов в нём сокращены до 2000 знаков. Обычное сжатие результаты не сокращает, кэш цел. Лимит шагов `maxSteps` — на весь ход: попытка после сжатия или перехода к запасному получает остаток. Расход всех попыток хода и сжатий попадает в журнал расходов и в расход ответа, даже если последняя попытка оборвалась. В журнал сервера пишутся строки `[переполнение] … сжатие=начато|готово|нечего сжимать|не вышло|выключено` и `… шаги ответа=…`. Приложению ничего менять не нужно.
1644
+ - 927c02c: Запасной провайдер той же модели и подключение надёжности к ходу (022, этап 4). Если выбранный провайдер после тихих повторов перегружен (`overloaded`) или упёрся в лимит (`rate_limited`), а ответ ещё не начался, ход один раз переходит к следующему провайдеру этой модели, чей ключ есть на сервере. Кэш при этом теряется, расход считается по цене запасного. После первого куска ответа, при неверном ключе, нехватке денег и прочих ошибках перехода нет. Новая настройка `AgentSettings.providerFallback`: не задано — включено, порядок из `providerModelIds`; `false` — выключено; `byModel[modelId]` — свой порядок или `false` для одной модели. Провайдер, которого нет у модели, или неизвестная модель — `TypeError` при создании службы. Ответ записывается с провайдером, который реально ответил: `FinishedMessagePatch.providerId?` (свои хранилища без этого поля работают как раньше), итоговый `message-metadata` и запись расхода. Выбор чата не меняется. Продолжение после подтверждения тоже переходит, одобренный инструмент не выполняется второй раз. Новая настройка `AgentSettings.toolResultMaxLength` (число или `{ default, byTool }`, не меньше 1000) задаёт предел результата инструмента; обрезка пишет в журнал строку `[результат обрезан]` без содержимого. Кривые аргументы инструментов чинятся без обращения к модели. Журнал сервера: `[повтор]` на каждый тихий повтор хода и сжатия, одна строка `[сбой провайдера]` на упавший ход, `[запасной провайдер] … кэш потерян` на переход. Экспортированы типы `ProviderFallbackSetting` и `ToolResultLimitSetting`.
1645
+ - be77ee2: Основа для надёжности хода агента (022). Новый вид ошибки `context_overflow` («Переписка не помещается в модель») у `ChatErrorKind` в ядре и в `react`, надпись `labels.errors.context_overflow` в русском словаре; Тандем показывает её сам. **Ломает:** исчерпывающий `switch` по виду ошибки и своя таблица надписей `labels.errors` без `context_overflow` перестанут проходить проверку типов — добавьте надпись. Ядро экспортирует `HANGING_TOOL_CALL_REASON` (текст для модели вместо результата прерванного вызова) и `cutAtCharBoundary(text, maxLength)` — обрезку без половинки эмодзи; веб-инструменты режут страницы и выдержки через неё, теперь эмодзи на границе не рвётся. Список перенесённых из pi-mono (MIT) признаков ошибок и лицензия — в `THIRD_PARTY_NOTICES.md`, он лежит и в пакете `core`.
1646
+ - 8b59096: Настоящий расход оборванных ответов и надёжнее переходы между попытками (022, этап 8б). «Стоп» или обрыв посреди ответа у OpenRouter сначала записывается оценкой «примерно», а затем сервер в фоне узнаёт у OpenRouter настоящий расход генерации по её номеру и дописывает запись `answer_correction` — разницу с оценкой (может быть меньше нуля); сумма записей ответа становится точной. Не узнал за ~10 минут или сервер перезапустился — остаётся оценка. Свой провайдер подключает то же новым необязательным полем описания `generationUsage` (номер генерации из заголовков и запрос расхода по номеру). Запрос, открытый, но оборванный до первого куска, теперь оценивается входом; отказ провайдера — нет. Расход хода пишется по каждому провайдеру отдельно и по его цене: основной, с которого ушли на запасной, — по своему тарифу; восстановление после перезапуска делает так же и считает ступень цены у каждого шага (идущая запись несёт `usage.pendingSteps`). У каждой попытки хода своя отмена: переход к повтору, запасному провайдеру или автопродолжению отменяет инструменты старой попытки; запущенный инструмент из `sequentialTools` держит очередь до своего конца, так что новая попытка не запустит второе «спиши деньги» поверх первого. Фоновая память после ответа идёт к провайдеру, который ответил, а не к лежащему основному. Старые картинки ужимаются один раз: кэш решений ограничен памятью копий, а не 20 местами, и чат с десятками старых картинок не ужимает их заново на каждом шаге. Память под копии — новая настройка `createAttachments({ limits: { imageCacheBytes } })`, по умолчанию 128 МБ.
1647
+ - 84ad64f: Укрепление по третьему проходу Pi (022, этап S9). **Большие фото уменьшаются при загрузке**: поворот по EXIF, не больше 2000×2000 пикселей и ~4,5 МБ в base64; хранится уменьшенная копия, старые картинки ужимаются при сборке запроса (результат запоминается), нечитаемая картинка и картинка больше 60 млн пикселей — отказ 422 `unreadable` (в старой истории — пометка «пропущена»). Новая зависимость `sharp` (готовые сборки для macOS и Linux, в том числе Alpine; порядок ужатия из pi-mono, MIT). **Секреты в ошибке инструмента** вычищаются до того, как текст уйдёт человеку, в базу и модели: пароль в адресе, `password=`/`password: …`/`token=`, `"secret": "…"`, заголовок `Authorization`, `X-Api-Key`, ключи сервисов, закрытые ключи PEM и значения секретных переменных окружения (кроме путей, адресов без пароля и простых слов); чистятся сообщение, стек и `cause`; `scrubSecrets` и `environmentSecrets` экспортированы из ядра. **Внешние картинки** в ответе модели и в сводке сжатия скин Тандема показывает ссылкой и не грузит (новая необязательная надпись словаря `externalImageLink`, по умолчанию «картинка»; свои словари без неё работают); инструкция веб-инструментов называет страницы данными, а не указаниями. **Ступени цены** по длине контекста: `pricing.tiers` (`inputTokensAbove` и цены), ход считается по шагам; у GPT 6 Luna выше 272 тысяч токенов. Пауза в `useAgentChat` больше не копит слушатели отмены.
1648
+ - 5027963: Тихие повторы и разбор ошибок провайдера (022). Повтор теперь понимает ошибку, пришедшую кусочком потока до первого куска ответа (код `code` или текст вроде «Provider returned error»), сетевые сбои (`fetch failed`, `socket hang up`), коды 408 и 409 и заголовок `x-should-retry`; квота, оплата и переполнение не повторяются. К паузам добавлен случайный разброс до 25% в пределах прежних ~10 с. Те же повторы действуют для запросов без потока: сжатие истории и склейка записей долгой памяти переживают одиночный 429 или 503. Если провайдер начал отвечать и замолчал (сроки — `agent.modelSilenceTimeout`, см. «Ход больше не висит вечно»), обрыв считается видом `overloaded`; ход дописывает ответ сам (автопродолжение), а не заканчивается ошибкой. Вид ошибки: 500, 502, 504 и временные сбои по тексту — `overloaded` (раньше `other`), признаки переполнения и 413 — `context_overflow`. Сбой сжатия и дел памяти пишет в журнал сервера одну строку `[сбой провайдера]` с кодом, именем ошибки и до 4000 знаков тела; ключи провайдеров, `Bearer …`, `sk-…`, `key=…` заменены на `[скрыто]`, заголовков нет. Склейка памяти пишет и строку `[повтор]` на каждую попытку.
1649
+ - c2769dd: «Повторить» после сбоя, «Стоп» или перезапуска сервера продолжает оборванный ход, а не начинает его заново. Новый ответ сразу показывает уже выполненные вызовы инструментов, и модель идёт дальше с них. Поэтому действия не повторяются: просили три задачи, оборвалось на третьей — после «Повторить» их ровно три, а не пять. Если выполненных вызовов не было, «Повторить» начинает ответ заново, как раньше. Прежний оборванный вариант остаётся соседним. Сменили модель или провайдера — рассуждения прежней модели в продолжение не попадают. Если провайдер перегружен, продолжение уходит к запасному. Исправлено падение сервера при «Стоп» во время браузерного инструмента. Приложению ничего менять не нужно.
1650
+ - 0cdf102: Несколько копий сервера на одной базе (022, этап 8в). Идущий ответ раз в 5 с отмечает «жив» (новая колонка `alive_at`: миграция SQLite `0012`, PostgreSQL `0011`, применяются сами). `recoverInterruptedAnswers` прерывает только брошенные ответы — без отметки дольше 15 с — и дальше сам проверяет их раз в 5 с до `flush`. Штатная остановка помечает свои ответы брошенными, и следующий старт прерывает их сразу. Новая копия при старте и обновление с перекрытием больше не обрывают живые ответы соседа; две копии, прервавшие один ответ, не задваивают расход. Цена: после падения сервера ответ ещё до ~20 с выглядит идущим (раньше перезапуск прерывал его сразу). Экран человека в это время сам перечитывает историю раз в 3 с и показывает «прерван» с «Повторить», как только сервер прервёт ответ; обновлять страницу не нужно (история отдаёт признак `detachedAnswer`, чтение истории сразу прерывает брошенный дольше порога ответ этого чата). Ход, чей ответ прервала другая копия, останавливается сам: человек видит «прерван», расход не задваивается. Время знака «жив» ставит хранилище: PostgreSQL — по часам базы, часы машин с копиями могут расходиться. Запросы одного чата держите на одной копии. Свои хранилища: два новых необязательных метода — `listSilentAnswerIds({ silentForMs, chatId? })` и лёгкая отметка `markAnswerAlive(messageId)` — и поле записи `alive` (`"now"` или `"released"`, время ставит само хранилище); общий набор контракта их проверяет. Хранилище без `listSilentAnswerIds` продолжает работать только с одной копией сервера: при старте `recoverInterruptedAnswers` прерывает все идущие ответы, проверок брошенных ответов дальше нет, а в журнал сервера пишется предупреждение. Без `markAnswerAlive` знак в тишине идёт полной записью ответа. Поле `alive` приходит в `insertMessage`, `updateStreamingMessage` и `updateAwaitingMessage`: если хранилище не ведёт знак, уберите поле перед записью, а не передавайте его в базу целиком. Переход на эту версию — один раз без перекрытия копий: старые копии не отмечают «жив», и новая прервала бы их ответы старше 15 с; сначала остановите все старые копии, потом запускайте новые. Дальше перекрытие безопасно. Если запрос истории попал не на ту копию, экран через 30 с сам покажет «прерван», хотя ответ идёт: держите запросы одного чата на одной копии. Сбой базы при конечной записи ответа повторяется через 1 и 3 с, а если не вышло — ответ остаётся для восстановления вместо вечного «идёт»; если запись прошла, а потерялся только ответ базы, повтор узнаёт свой итог и считает его удачным. Инструмент «по очереди», не слушающий отмену, после обрыва попытки держит очередь не дольше своего срока; очередь одна на чат, так что «Стоп» и сразу «Повторить» не запустят его второй раз параллельно. Восстановление пропускает ответ, на котором упало, с записью в журнал сервера, и прерывает остальные: битая запись не мешает серверу стартовать. Мелочи: адрес ответа вкладки на браузерный инструмент отвечает 413 `too_large` на слишком большое тело; неотвеченный запрос подтверждения закрывается только после сохранения нового сообщения, а если закрыть не вышло — повтор того же сообщения или «Повторить» доводят дело до конца; вызов веб-инструмента, оборванный «Стопом» или сбоем после отправки запроса, пишется в расход `web_tools`; картинки при загрузке ужимаются через очередь — разом не больше половины ядер (`limits.maxParallelImageFits`).
1651
+ - ba16989: Предел размера результата инструмента и починка кривых аргументов (022, этап 3). `trackToolErrors(tools, { limit, onCut })` режет результат любого инструмента с `execute` до предела (по умолчанию 50 000 знаков, пометка «[Результат обрезан: показано N из M знаков]» входит в предел, эмодзи не рвётся); инструменты с `toModelOutput` не режутся. `resolveToolResultLimit` проверяет настройку (целое не меньше 1000, иначе `TypeError`). `createRepairToolCall()` — параметр `repairToolCall` для `streamText`: чинит сырые переводы строк и неверные `\` в аргументах и приводит `"2"` к числу, `"true"` к булеву значению по схеме инструмента. `repairJson` перенесён из pi-mono (MIT).
1652
+ - aba59fe: Цикл агента и учёт расхода крепче (022, этап 8). Когда шаги хода кончаются, последний запрос идёт без инструментов (счёт по шагам всего хода, ход из одного шага не ограничивается) и с пометкой «подведи итог» в самом конце: ход кончается сводкой для человека, а не обрывом на вызове. Новая настройка `agent.sequentialTools` (`true` или список имён) пускает инструменты с последствиями по одному: «спиши деньги» не пойдёт одновременно с другим таким вызовом; очередь одна на чат (во всех ходах и повторах), ожидание очереди не съедает срок инструмента, «Стоп» отменяет ждущий вызов, и модель видит, что он не запускался. Оборванный вызов инструмента теперь говорит модели, что действие могло выполниться частично, и показывает последний промежуточный вывод. Ответ, упёршийся в предел длины, получает отметку: человек видит надпись «Ответ оборван по длине» (`labels.answerCutByLength`, в скине — `AgentMessage.cutByLength`), модель в следующих ходах видит пометку в конце своего ответа. Ответ из одних рассуждений (например, «Стоп» на рассуждении) больше не идёт в запрос, а сообщения человека, оказавшиеся подряд, склеиваются в одно (строгие провайдеры не принимают два подряд). Сегодняшняя дата словами идёт в конце последнего сообщения человека, только в запросе — начало запроса не меняется, кэш цел. Дата берётся по часовому поясу человека: браузер шлёт свой пояс (`timeZone`) с каждым запросом хода, сервер берёт только настоящий пояс IANA, иначе — новая настройка `agent.timeZone`, без неё — пояс сервера. Ни один ответ больше не бесплатен в журнале расхода: шаг, оборванный «Стопом» или сбоем после первых слов, и шаг, по которому провайдер не прислал числа, прикидываются по длине текста с учётом письма (кириллица, JSON и иероглифы плотнее латиницы; прикидка скорее завышает), а ответ, оборванный перезапуском сервера, записывается при следующем запуске (`recoverInterruptedAnswers`): только то, чего ещё нет в журнале, поэтому сбой после «Разрешить» не задваивает расход. Такие записи и итог ответа помечены «примерно» (`estimated`). В SQLite и PostgreSQL у записей расхода новая колонка `estimated`: миграция применяется сама при открытии хранилища. Своим хранилищам менять ничего не нужно: без колонки теряется только пометка. Фильтр `sumUsage` получил поля `messageId` и `source`: свои хранилища должны их учитывать (общие тесты `describeChatStorageContract` это проверяют).
1653
+ - 47a56ac: Ход больше не висит вечно (022, этап 6б). Серверный инструмент, который не ответил за 2 минуты, получает отмену через `abortSignal`, а модель видит ошибку «Инструмент не ответил за 120 с и отменён. Выполнено ли действие, неизвестно; не повторяй его, не проверив» и дописывает ход. Срок — настройка `agent.toolTimeoutMs` (по умолчанию 120 000 мс, 0 или `Infinity` — без предела), свой срок отдельного инструмента — `{ default?, byTool? }`, как у `toolResultMaxLength`: например, `{ byTool: { ask_user: 0 } }` для инструмента, который сам ждёт ответа человека. Инструменты с подтверждением ждут «Разрешить» без срока, срок идёт с запуска. Браузерные инструменты живут по своим 30 с, как раньше. Сроки молчания потока модели стали настройкой `agent.modelSilenceTimeout`: до первого куска ответа теперь 90 с вместо 60 (модели с долгими рассуждениями и длинным контекстом успевают ответить), между кусками — 120 с, как было; `false` — без предела. Молчание до первого куска повторяется у того же провайдера только один раз, затем — запасной провайдер, а без него ошибка: лёгший провайдер держит ход около 3 минут. Молчание посреди ответа идёт в автопродолжение. Время работы инструмента молчанием модели не считается. После срабатывания любого предела место хода освобождается, «Стоп» работает как раньше, запрос к модели не меняется — кэш цел. Журнал сервера: `[инструмент не ответил] чат=… инструмент=… срок=…мс` и `[молчание модели] … ждали=первый-кусок|следующий-кусок срок=…мс`. Приложению ничего менять не нужно; неверное значение настройки — ошибка при создании службы.
1654
+ - f9b6162: Предел одновременных ответов одного человека. Проверка лимита расходов не резервирует деньги, поэтому 20 ходов, начатых разом, могли вместе потратить в разы больше бюджета. **Меняется поведение по умолчанию:** у человека одновременно идут не больше трёх ответов во всех его чатах; лишний ход (отправка, повтор, переделка, правка, продолжение после «Разрешить») получает `429 { error: { kind: "too_many_turns", limit } }` до любых записей. Настройка `maxConcurrentTurnsPerUser` у `createChatHandler` и `createChatService` (целое от 1 или `Infinity` — без предела; иначе ошибка при создании); ядро экспортирует `TooManyTurnsError` и `DEFAULT_MAX_CONCURRENT_TURNS_PER_USER`. Клиент показывает «Уже идут ответы в других чатах…», текст возвращается в поле, без «Нет связи». Заодно истёкший вход (401) клиент показывает как «Вход истёк. Войдите снова» с «Повторить», а не как «Нет связи» или «Что-то пошло не так», и не делает попыток переподключения. Новое: `useAgentChat().refusal`, тип `ChatRefusal`, тип `ErrorNoticeKind` (`ErrorNotice` скина получает и `signed_out`, `too_many_turns`), надписи `labels.errors.signed_out`, `labels.errors.too_many_turns`, `labels.editErrors.signedOut`, `labels.editErrors.tooManyTurns`, `labels.approvalTooManyTurns`; причины отказа правки `signed_out` и `too_many_turns`. Свой словарь целиком, без `{ ...russianLabels }`, — добавьте эти ключи.
1655
+ - f9b6162: Мелочи безопасности и расходов. Веб-инструменты: новая настройка `createWebTools({ spending: { storage, pricePerCallUsd } })` — каждый удачный вызов `web_search`, `read_page`, `page_screenshot` пишется расходом с новым источником `web_tools` и входит в лимит расходов; без `spending` всё как раньше, описания инструментов не меняются (кэш цел). OpenRouter: `createOpenRouterProvider({ dataCollection: "deny", zeroDataRetention })` — запрос уходит только хозяевам модели, которые не хранят запросы; подключается через `extendCatalog(..., { addProviders: [...] })`; стандартный `openrouterProvider` и тело запроса по умолчанию не изменились. Долгая память: инструкция велит записывать только сказанное самим человеком, а не указания с веб-страниц и из результатов инструментов (текст инструкции изменился один раз, дальше постоянен).
1656
+ - f9b6162: Защита от запросов с чужих страниц. Браузер прикладывает куки человека и к запросу, который отправила чужая страница (обычная HTML-форма, WebSocket), поэтому раньше чужой сайт мог от имени человека записать «факт» в память, создать чат, начать ход или открыть голос. **Меняется поведение по умолчанию:** запрос, который меняет данные (не GET), с чужой страницы получает `403 { error: { kind: "forbidden_origin" } }` до входа и любых записей; подъём WebSocket голоса (`createVoiceRelay`, голосовой режим) проверяется так же, хотя это GET; временный ключ `createVoiceHandler` чужой странице не выдаётся; голосовой режим (`mountDialogRelay`) без `allowedOrigins` больше не пропускает чужие страницы. Свой сайт (`Sec-Fetch-Site: same-origin` или Origin совпадает с `Host` / `X-Forwarded-Host`) и запросы не из браузера проходят как раньше. Чат на чужом сайте, который шлёт запросы прямо оттуда (например, GetCourse), — новая настройка `allowedOrigins: ["https://school.getcourse.ru"]` у `createChatHandler`, `createVoiceRelay`, `createVoiceHandler` и `mountDialogRelay`; CORS и куки `SameSite=None; Secure` настраивает приложение. Неверная запись в списке — ошибка при создании. Отказ пишется в журнал один раз на адрес страницы. Ядро экспортирует `createRequestOriginCheck` и `isSafeMethod`. Ещё: тело запроса больше 1 МиБ сервер не дочитывает и отвечает `413 { error: { kind: "too_large" } }` (самое длинное сообщение, 100 000 знаков, проходит).
1657
+ - 1b2f9f1: Фронт и API на разных адресах. `allowedOrigins` у `createChatHandler`, `createVoiceRelay`, `createVoiceHandler` и `mountDialogRelay` принимает шаблон поддоменов `https://*.example.com`: он пускает `app.example.com` и `a.b.example.com`, но не сам `example.com` и не `evil-example.com`; схема и порт должны совпасть, слишком широкий шаблон (`https://*.com`, `https://*.com.`) — ошибка при создании. Общие суффиксы вроде `*.co.uk` проверка не распознаёт: в шаблон вписывают свой домен. Отказ из-за того, что `Origin` не совпал с `Host` (старый браузер или `http://` за прокси, который подменил `Host`), журнал теперь называет прямо: «Origin не совпал с Host (…); за прокси передайте Host/X-Forwarded-Host» с готовыми строками для nginx. `onRefused` у `createRequestOriginCheck` получает `{ origin, reason, hosts? }` вместо строки; новые экспорты ядра — `originRefusalHint` и тип `OriginRefusal`. Клиент на 403 с `forbidden_origin` в теле показывает «Сервер чата не принимает сообщения с этой страницы» (у правки — только в редакторе), возвращает текст в поле и в режиме разработки пишет в консоль, какой адрес вписать в `allowedOrigins`; другой 403 (например, HTML-страница прокси) — обычный сбой. Новое: `ChatRefusal` и `ErrorNoticeKind` получили `forbidden_origin` (`ErrorNotice` скина может получить этот `kind`), причина отказа правки `forbidden_origin`.
1658
+
1659
+ **Изменение договора надписей:** в `ChatLabels` два новых обязательных ключа — `errors.forbidden_origin` и `editErrors.forbiddenOrigin`. Словарь на основе `{ ...russianLabels }` получает их сам; свой словарь целиком — дополните ими, иначе ошибка типов. Отдельного номера версии у договора надписей нет. Ещё: при отказе по месту хода (`too_many_turns`) или занятом чате файлы вложений больше не принимаются и не остаются ничьими; пока файлы принимаются, чат занят, и второе сообщение в него получает 409.
1660
+ - 0e8e0ae: Голосовой режим: человек продолжил говорить, пока модель ещё думает, — вторая фраза дописывается к первой, а не уходит вторым сообщением. Ход останавливается, вместо прежнего сообщения уходит склеенное («Так. Дай-ка подумать.»), модель отвечает один раз; пустого оборванного ответа и отдельного «Так.» в чате нет.
1661
+
1662
+ - Склеивается, пока у хода нет ни текста, ни звука, ни начатого вызова инструмента (размышление и заполнитель не мешают). Текст или звук уже пошли, начат инструмент, сказано стоп-слово или два междометия — поведение прежнее: перебивание и отдельное сообщение. Подробности — в README пакета voice-dialog, раздел «Склейка фраз».
1663
+ - Склейка идёт через правку последнего сообщения человека: старая фраза остаётся в базе, но обрывком склейки: переключателя веток у склеенного сообщения нет, пролистать на обрывок нельзя (если человек потом сам правит склеенное сообщение, появляется настоящая развилка без обрывков). Признак — часть `data-voice-mode` склеенного сообщения (`{ replaces: id }`), без миграции базы. Отметка голосового режима и подсказки режима сохраняются, кэш модели цел. Название чата, выведенное из заменённого сообщения, обновляется.
1664
+ - `core`: `chatService.editMessage(userId, chatId, editedMessageId, message, options?)` принимает `{ voiceMode: true }`. `server`: `POST …/edit` принимает необязательное булево `voiceMode` (не булево — 400 `invalid`).
1665
+ - `react`: у состояния чата появился `replaceLastVoiceMessage(text)`, у моста `DialogChatBridge` — необязательный `replaceLastMessage(text)`; `useVoiceDialog` его реализует.
1666
+
1667
+ Обновление ничего не требует: движок склеивает сам, если мост и сервер той же версии. Приложение со своим мостом чата без `replaceLastMessage` работает как раньше: новая фраза в ожидании ответа перебивает ход и уходит отдельным сообщением.
1668
+ - 6b20d83: Голосовой режим («разговор»): человек говорит, слова сами уходят сообщением в чат, ответ агента звучит голосом, человека можно перебить. Новый пакет `@ykurilov/agent-chat-voice-dialog`: распознавание Soniox, озвучка ElevenLabs (голос по умолчанию «Профессиональный диктор для аудиокниг», модель `eleven_v4_turbo`), ключи только на сервере. Перебитый ответ остаётся в ленте с отметкой «Вы перебили: озвучено до «…»», она хранится и видна после перезагрузки. Расход на озвучку идёт в учёт и лимиты (`source: "speech"`). Человек выбирает голос в меню режима; выбор хранится на сервере.
1669
+
1670
+ Модель узнаёт о режиме двумя служебными пометками: одна при входе («ответы озвучиваются, отвечай коротко и разговорно»), одна при возврате к тексту («разговор снова текстом»). Они стоят в конце сообщения человека, где режим сменился, а не в каждом запросе. Сообщение из режима запоминается голосовым отметкой в самом сообщении (часть `data-voice-mode` в `parts`), поэтому миграции для этого нет, пометки выводятся из сохранённого и кэш модели цел. Тексты задают настройки агента `voiceModeHint` и `textModeNote` (есть умолчания).
1671
+
1672
+ Обновление ничего не ломает: без нового пакета чат работает как раньше. Диктовка в поле ввода (`@ykurilov/agent-chat-voice`) не меняется.
1673
+
1674
+ ### Как обновить приложение
1675
+
1676
+ Нужно, только если вы хотите голосовой режим.
1677
+
1678
+ 1. Поставьте пакет `@ykurilov/agent-chat-voice-dialog` той же версии, что и остальные пакеты SDK. Скин Тандем обновите вместе с SDK: договор скина теперь версии 10 (необязательный `dialog` у поля ввода, панель режима, меню голосов, отметка обрыва; свой скин может их не рисовать, тогда кнопки режима не будет).
1679
+ 2. Сервер. Рядом с чатом подключите режим (нужен WebSocket; с Hono и `@hono/node-ws`):
1680
+
1681
+ ```ts
1682
+ import { mountDialogRelay } from "@ykurilov/agent-chat-voice-dialog/hono";
1683
+
1684
+ mountDialogRelay(app, {
1685
+ path: "/api/voice-dialog",
1686
+ upgradeWebSocket,
1687
+ storage,
1688
+ resolveUser, // тот же вход, что у чата
1689
+ spendingLimits, // по желанию
1690
+ logError: (message) => console.error(message),
1691
+ speech: {
1692
+ voices: [{ id: "oE0n0S3wK5Mj9C62iehn", name: "Профессиональный диктор для аудиокниг" }],
1693
+ defaultVoiceId: "oE0n0S3wK5Mj9C62iehn",
1694
+ pricePerThousandChars: { eleven_v4_turbo: 0.11, eleven_v4: 0.22 }, // ваша цена, $ за 1000 знаков
1695
+ },
1696
+ });
1697
+ ```
1698
+
1699
+ Ключи `ELEVENLABS_API_KEY` и `SONIOX_API_KEY` — в окружении сервера. Next.js: WebSocket нужен свой Node-сервер, см. README пакета (`createDialogRelay`).
1700
+ 3. Браузер:
1701
+
1702
+ ```tsx
1703
+ const voiceDialog = createVoiceDialogEngine({ basePath: "/api/voice-dialog" });
1704
+ <AgentChat api="/api/chat" skin={tandemSkin} voiceDialog={voiceDialog} />
1705
+ ```
1706
+
1707
+ 4. Настройки. Слова остановки и междометия по умолчанию только русские; для другого языка задайте `bargeIn.stopWords` и `bargeIn.ignoreWords` у движка и `language` на сервере. Строгая политика содержимого страницы: добавьте `worklet-src blob:`.
1708
+ 5. Хранилище. SQLite получает миграцию 0008, PostgreSQL — 0007 (схема `agent_chat`) сами при открытии базы. Свой `ChatStorage`: добавьте необязательные методы `setVoiceCut`, `getVoiceChoice`, `setVoiceChoice` (без них отметка обрыва и выбор голоса живут только до перезагрузки) и значение `"speech"` в `UsageSource`, если учёт расхода у вас свой.
1709
+ 6. Свой экран (не наш скин): части сообщения с типом `data-voice-mode` не рисуйте, показывайте только текстовые части.
1710
+ 7. Проверка: `pnpm typecheck` проходит; в чате появилась кнопка режима, после нажатия виден статус «Слушаю», фраза уходит сообщением, ответ звучит.
1711
+ - d12dcf3: Голосовой режим: звуки человека (смех, вздох, кашель…) доходят до модели.
1712
+
1713
+ - Звук с микрофона параллельно с Soniox уходит с нашего сервера в ElevenLabs Scribe (`scribe_v2`, `tag_audio_events`); слова Scribe не использует, ключ остаётся на сервере. Звук уходит кусками прямо во время фразы: каждые `sounds.chunkSeconds` (1,5 с) нового звука, с перекрытием `sounds.overlapSeconds` (0,5 с), на конце фразы — только хвост; повторы на перекрытии схлопываются. Тишина (тише `sounds.silenceRms`, 0,01) не отправляется. На конце фразы ждётся только хвост и ещё не вернувшиеся куски, не дольше `sounds.waitMs` (1000 мс, по замеру Scribe отвечает за 0,7–0,95 с): не успел — звуки приклеятся к следующей фразе. Куски не копятся в очередь (не больше двух запросов). Расход считается по фактически отправленным секундам, включая перекрытия. Фраза из одного смеха без слов ход модели не запускает.
1714
+ - Звуки лежат в сообщении человека, в части `data-voice-mode` (`data: { sounds: ["laughter"] }`), уходят на сервер как `voiceSounds` вместе с `voiceMode` (значение не из закрытого списка — 400 `invalid`). Пометка для модели («были слышны его звуки: смех») выводится из сохранённого при сборке запроса: начало запроса от хода к ходу не меняется, кэш цел; склейка фраз сохраняет звуки обеих. На экране звуков нет. В пометке входа в режим появилось короткое правило про пометку звуков.
1715
+ - Эхо: звук того же вида, пойманный, пока играл кусок озвучки агента с таким же звуком (тег «[laughs]», заполнитель, отдельное «Ха-ха!»), плюс короткий хвост на задержку микрофона (`soundEchoTailMs`, 0,6 с), звуком человека не считается; смех в ответ на шутку агента доходит.
1716
+ - Ошибка или недоступность Scribe режим не ломает: разговор идёт без звуков, в журнал — предупреждение. Расход пишется записью `source: "sounds"` (`inputTokens` — секунды звука) и входит в лимиты расходов.
1717
+ - Новое: `sounds` у `createDialogRelay` и `mountDialogRelay` (`pricePerHour` обязательна), `sendVoiceMessage(text, { sounds })` и `replaceLastVoiceMessage(text, { sounds })` (`react`, `DialogChatBridge.send/replaceLastMessage` получили `options`), `voiceSounds` у `SendMessageOptions` и в теле `…/messages`, `…/edit`, экспорты `core`: `VOICE_SOUNDS`, `isVoiceSound`, `uniqueVoiceSounds`, `voiceSoundsNote`, `soundsOfMessage`, `UsageSource` получил `"sounds"`; `react`: тип `VoiceSound`; `voice-dialog/server`: `createScribeSounds`, `createSoundsUsage`, `HUMAN_SOUND_KINDS`.
1718
+
1719
+ Как обновить приложение: ничего не требуется, пока `sounds` не задан. Чтобы включить, добавьте `sounds: { pricePerHour }` (цену Scribe берите из прайса ElevenLabs; v2 — 0,22 $ за час) в настройки режима; ключ тот же `ELEVENLABS_API_KEY`. Своё хранилище расходов должно принимать `source: "sounds"` (текстовое поле, миграций не нужно). Пометка входа в режим по умолчанию стала длиннее на одно правило: кэш модели у идущих голосовых чатов один раз промахнётся. Свой `agent.voiceModeHint` (строка) заменяет пометку входа целиком, правило про звуки в нём не появится, но пометка звуков у сообщений всё равно добавится.
1720
+ - 366d785: Голосовой режим: свой набор тегов эмоций у каждого голоса.
1721
+
1722
+ - В описании голоса (`speech.voices[].tags`) можно задать `{ allowed, avoid? }`: какие теги голосу идут и какие нет. Модель получает список текущего голоса в пометке входа в режим. Голос без набора получает общий список, его пометка не меняется ни на байт.
1723
+ - Голос человека сохраняется на его сообщении в части `data-voice-mode` (`data: { voice: id }`, без миграции базы). Если голос сменился посреди разговора, первое сообщение с новым голосом один раз получает пометку с его тегами; голоса с одинаковым списком пометки не получают. Пометки выводятся из сохранённых отметок: повтор ответа, правка, перезагрузка и возврат к тексту дают то же начало запроса, кэш модели цел.
1724
+ - Новое: `agent.voices` у `createChatService` и `createChatHandler` (интерфейс `VoiceTagSource`), `speech.catalog` у `mountDialogRelay` и `createDialogRelay` (готовый каталог голосов вместо `voices` и `defaultVoiceId`), `VoiceCatalog.tagsOf`, экспорты `core`: `VoiceTagSet`, `VoiceTagSource`, `voiceModeHint`, `voiceChangeNote`, `assertVoiceTagSet`, `sameVoiceTagSet`, `voiceOfMessage`. Наборы проверяются при запуске по правилу тега с экрана; экрану в `GET …/voices` они не уходят.
1725
+
1726
+ Как обновить приложение: ничего не требуется, пока наборов нет. Чтобы задать наборы, создайте каталог `createVoiceCatalog({ voices, defaultVoiceId, storage })` и передайте его и чату (`agent.voices`), и режиму (`speech.catalog`); подробности — в README пакета, раздел «Свой набор тегов у каждого голоса». Если вы задавали свой `voiceModeHint` (строка), он по-прежнему заменяет пометку целиком, без пометок о тегах голоса.
1727
+ - 946e5d8: Голосовой режим: теги эмоций и подачи голоса.
1728
+
1729
+ - Модель получает список тегов Eleven v4 («[laughs]», «[sighs]», «[whispers]», «[excited]» и других) и может ставить их в ответ; голос их отыгрывает, а на экране их нет: ни в тексте ответа, ни при копировании, ни в поиске по прошлым разговорам. Это касается только ответов голосовых ходов (сообщение человека с отметкой `data-voice-mode`): в текстовом режиме и в сводке сжатия скобки остаются как есть («[docs]», «[error]» там обычные слова). Обычные скобки («[1]», ссылки, чекбоксы, код) не трогаются. В истории для модели теги остаются, кэш цел.
1730
+ - Одиночный тег больше не обрывает реплику: сервер не отправляет в озвучку текст без букв и цифр, а тег приклеивается к соседнему предложению; тег в конце ответа («Да. [laughs]») — к последнему.
1731
+ - Смех и вздох агента в микрофоне не считаются перебиванием: звуки сверяются по шаблону («ха-ха-ха-ха», «ооох»), составные слова («Дай-ка») — по частям.
1732
+ - Новые экспорты: `stripVoiceTags`, `splitVoiceTags` (`core` и `react`), `voiceAnswerIds` (`react`), `isVoiceMessage`, `VOICE_TAGS`, `DEFAULT_VOICE_MODE_HINT`, `DEFAULT_TEXT_MODE_NOTE` (`core`). `messageText` (`react`) и `searchableText` (`history-search`) получили параметр `{ voiceAnswer }`. `sendVoiceMessage` кладёт отметку `data-voice-mode` в сообщение сразу.
1733
+
1734
+ Как обновить приложение: ничего не требуется. Пометки по умолчанию обновятся сами; кэш модели у идущих голосовых чатов один раз промахнётся. Если вы задавали свои `voiceModeHint` и `textModeNote`, теги в них не появятся: добавьте их сами, если нужны. Свой скин, который сам выводит текст ответа, должен вырезать теги только у ответов голосовых ходов (`voiceAnswerIds`). Побочный эффект: в ответе голосового хода слово в скобках, похожее на тег («[Google]»), тоже не показывается.
1735
+
1736
+ ### Patch Changes
1737
+
1738
+ - b6f4855: OpenRouter: шифр рассуждений GPT и подпись рассуждений Gemini больше не теряются на ходах без вызова инструмента. Раньше провайдер SDK оставлял их только в итоге шага, и ни в хранилище, ни в следующем запросе их не было; модель получала историю без своих прежних рассуждений (у Gemini в консоль шло предупреждение «reasoning_details entries were removed because they were missing signatures»).
1739
+
1740
+ - Модель OpenRouter обёрнута: полные детали шага сохраняются вместе с текстом ответа, а перед запросом переносятся на сообщение агента.
1741
+ - Ответы чужой модели по-прежнему уходят без рассуждений и шифра.
1742
+ - Сами модели загаданное из рассуждений пока не вспоминают (живая проверка в `specs/003-models-and-picker/live-results.md`); правка убирает потерю на нашей стороне.
1743
+
1744
+ Обновление ничего не требует.
1745
+ - 7fbc105: Вызов инструмента без результата больше не ломает чат. Раньше после «Стоп» во время инструмента, обрыва провайдера или перезапуска сервера любой следующий вопрос в этом чате сразу получал ошибку, а модель даже не вызывалась (`AI_MissingToolResultsError`); сжатие такого чата тоже падало. Теперь каждый такой вызов (`input-available` или только промежуточный результат) перед запросом к модели закрывается ошибкой с постоянным текстом `HANGING_TOOL_CALL_REASON`: «Вызов инструмента оборвался до результата: действие могло выполниться частично или целиком. Не повторяй его, не проверив, что уже сделано». Закрытие делается при каждой сборке запроса одинаково, хранилище не меняется, кэш модели цел. Конец хода со статусом `stopped` или `error` и `recoverInterruptedAnswers` записывают такое же закрытие и в хранилище; запросы подтверждения закрываются прежними текстами. Заодно из запроса вырезаются непарные половинки эмодзи в тексте, рассуждениях и вызовах инструментов, в том числе из старой истории; целые эмодзи остаются.
1746
+
1747
+ ## 0.13.0
1748
+
1749
+ ### Minor Changes
1750
+
1751
+ - 4793e4d: Чат запоминает последний выбор человека: провайдера, модель и уровень рассуждений. Новый чат открывается на них, а не на модели по умолчанию. Если модели больше нет в каталоге или у её провайдера нет ключа, новый чат открывается на выборе по умолчанию из настроек; уровень, которого у модели нет, заменяется её уровнем по умолчанию. У каждого человека свой выбор, старые чаты с сообщениями остаются на своём, а пустой чат, который открывается кнопкой «Новый чат», переходит на последний выбор. Запоминается последний выбор, в том числе сделанный в старом чате. При двух открытых вкладках побеждает позже пришедший запрос. Выбор хранится по человеку и общий для всех обработчиков на одной базе; недоступная модель заменяется моделью по умолчанию. Новый чат сразу показывает запомненный выбор, без мигания моделью по умолчанию.
1752
+
1753
+ Обновление ничего не требует: SQLite и PostgreSQL получают новую таблицу сами при открытии базы (SQLite — миграция 0007, PostgreSQL — миграция 0006 в схеме `agent_chat`), встроенное хранилище в памяти тоже умеет запоминать.
1754
+
1755
+ ### Как обновить приложение со своим хранилищем
1756
+
1757
+ Нужно только если приложение передаёт в `createChatHandler` собственное хранилище (свою реализацию `ChatStorage`). Без этого шага всё продолжит работать как раньше: новые чаты будут открываться на `defaultChoice`.
1758
+
1759
+ 1. В своём хранилище добавьте два необязательных метода из `ChatStorage`:
1760
+
1761
+ ```ts
1762
+ getUserModelChoice(ownerId: string): Promise<{ modelId: string; providerId: string; reasoning?: string } | null>;
1763
+ saveUserModelChoice(ownerId: string, choice: { modelId: string; providerId: string; reasoning?: string }): Promise<void>;
1764
+ ```
1765
+
1766
+ Один выбор на человека (`ownerId`): повторная запись заменяет прежний, запись без `reasoning` стирает сохранённый уровень. С чатами выбор не связан: удаление чата его не трогает. Пример запросов — в `packages/storage-sqlite/src/sqlite-storage.ts`.
1767
+ 2. Добавьте таблицу или коллекцию под выбор (SQLite: `owner_id` — первичный ключ, `model_id`, `provider_id`, `reasoning` — может быть пустым, `updated_at`).
1768
+ 3. В тестах хранилища вызов `describeChatStorageContract(createStorage)` сам проверит новые методы; если они есть, проверки пройдут, если нет — будут пропущены.
1769
+ 4. Проверка: `pnpm typecheck` проходит; сменить модель в чате, открыть новый чат — на нём та же модель.
1770
+
1771
+ ## 0.12.0
1772
+
1773
+ No changes in this release.
1774
+
1775
+ ## 0.11.0
1776
+
1777
+ No changes in this release.
1778
+
1779
+ ## 0.10.0
1780
+
1781
+ ### Minor Changes
1782
+
1783
+ - 4a76ff3: **Ломает приложения:** у `ChatStorage` появились обязательные операции `updateAwaitingMessage` и `getMessage`, у словаря `ChatLabels` — новые ключи `approval*`; касается приложений со своим хранилищем и с полным словарём надписей.
1784
+
1785
+ Подтверждение перед опасными действиями агента. Инструмент, помеченный `needsApproval: true` (или функцией от входных данных), не выполняется без разрешения человека: ход останавливается, в ленте появляется карточка с названием действия, входными данными и кнопками «Разрешить», «Отклонить» и «Всегда в этом чате». Кнопка «Всегда» есть только у инструмента с `needsApproval: true`; у инструмента с функцией её нет, каждый вызов спрашивает заново. Ждать можно сколько угодно: состояние лежит на сервере, переживает перезагрузку страницы и перезапуск сервера, ответить можно с любого устройства.
1786
+
1787
+ Что изменилось:
1788
+
1789
+ - `@ykurilov/agent-chat-core`: статус ответа `awaiting_approval`; `answerApproval` у сервиса чата; ответ после решения дописывается в то же сообщение, инструмент выполняется ровно раз. «Отклонить» — модель получает «пользователь отклонил» и продолжает. Новое сообщение вместо ответа на карточку считается отказом. Сбой или «Стоп» после «Разрешить» не оставляют вызов без итога: он закрывается как прервавшийся («результат неизвестен», если инструмент уже мог сработать). Ответ на карточку проверяет лимит расходов (429), а расход паузы и продолжения не считается дважды. После смены модели во время ожидания продолжение идёт на модели ждущего сообщения. Кэш модели не страдает: инструкция и набор инструментов не меняются.
1790
+ - `@ykurilov/agent-chat-server`: адрес `POST /:chatId/approvals/:approvalId` с телом `{ "decision": "allow" | "deny" | "always" }`. Ошибки: 409 `busy` (в чате идёт другой ход), 409 `approval_not_pending` (на этот запрос отвечать уже нечем), 429 `spending_limit_reached`.
1791
+ - `@ykurilov/agent-chat-storage-sqlite`, `@ykurilov/agent-chat-storage-postgres`: реализуют `updateAwaitingMessage` и `getMessage`. Схема базы не меняется, миграции не нужны: статус ответа хранится обычным текстом.
1792
+ - `@ykurilov/agent-chat-react`: договор скина версии 9 — у `ToolCallRow` необязательные `title` и `approval`; хук `useAgentChat` отдаёт `respondToApproval`, `approvalPendingId`, `approvalFailure` (вид сбоя: нет связи, чат занят, лимит расходов, запрос уже не ждёт); при неудачной отправке карточка возвращается к кнопкам, а не остаётся «отклонённой». У `ToolApprovalView` в состоянии `requested` есть `canAlways` (показывать ли «Всегда») и необязательный `failureText`. Словарь получил ключи `approvalRequested`, `approvalWaiting`, `approvalAllow`, `approvalDeny`, `approvalAlways`, `approvalFailed`, `approvalSpendingLimit`, `approvalBusy`, `approvalAllowedMark`, `approvalAlwaysMark`, `approvalDeniedMark`, `approvalAnnouncement`.
1793
+ - `@ykurilov/agent-chat-skin-tandem`: карточка подтверждения (группа с названием для читалки, «Ждёт вашего решения» и итог озвучиваются, фокус не теряется, на телефоне длинные поля не выпирают) и пометка решения в строке вызова.
1794
+
1795
+ ### Как обновить приложение
1796
+
1797
+ Если опасных инструментов нет, а хранилище и скин стандартные, обновление ничего не требует.
1798
+
1799
+ 1. Своё хранилище (`ChatStorage`): добавьте `getMessage(messageId)` — вернуть сообщение по номеру или `null`. И добавьте `updateAwaitingMessage(messageId, patch)` — перезаписать `parts` ответа со статусом `awaiting_approval` и, если в `patch.status` указано, сменить статус на `"streaming"` или `"complete"`; ответ не в этом статусе — бросить `MessageNotAwaitingError`. Готовый образец — `packages/storage-sqlite/src/sqlite-storage.ts`. Проверка: `describeChatStorageContract` из `@ykurilov/agent-chat-core/storage-contract` проходит.
1800
+ 2. Полный свой словарь надписей (`ChatLabels`, объект без `...russianLabels`): добавьте двенадцать ключей `approval*` из списка выше. Словарь `{ ...russianLabels, … }` менять не нужно.
1801
+ 3. Свой скин: если `ToolCallRow` свой, добавьте разбор `approval` — `{ state: "requested", onDecide, pending, failed, failureText?, canAlways }` (нарисовать кнопки, `onDecide("allow" | "deny" | "always")`; «Всегда» показывать только при `canAlways`; при `failed` показать `failureText`, если он есть) и `{ state: "allowed" | "always" | "denied" }` (итог решения). Нет `approval` — рисуйте строку как раньше. Название действия — в `title`.
1802
+ 4. Чтобы включить подтверждение: у опасного инструмента задайте `needsApproval: true` (можно функцией от входных данных; с функцией кнопки «Всегда» не будет) и `title` — название для карточки. Больше ничего подключать не надо.
1803
+ 5. Проверка: `pnpm typecheck`; попросите агента вызвать опасный инструмент — в ленте карточка, после «Разрешить» действие выполнено, ответ дописан в то же сообщение.
1804
+
1805
+ ## 0.9.0
1806
+
1807
+ ### Minor Changes
1808
+
1809
+ - d07a1c3: **Ломает приложения:** только если вы сами реализовали `ChatStorage` или собрали словарь надписей целиком (без `{ ...russianLabels, … }`); остальным ничего делать не нужно.
1810
+
1811
+ Новый пакет `@ykurilov/agent-chat-limits`: лимиты расходов. Бюджет в долларах на человека, на чат или на то и другое; период — календарный месяц (по умолчанию), сутки или без обнуления. Исчерпанный лимит останавливает ход до его начала; при 80% чат предупреждает.
1812
+
1813
+ Что изменилось:
1814
+
1815
+ - Ядро: цены в профиле модели (`ModelProfile.pricing` по провайдерам; вход, выход, чтение и запись кэша, рассуждения считаются как выход). Цены есть у всех стандартных моделей. Журнал расхода `usage_records` — токены и стоимость в долларах (`costUsd`, по ценам модели на момент хода) каждого ответа и фонового сжатия, в том числе сжатия, которое не удалось, но было оплачено; запись датируется началом хода. Журнал только дописывается и переживает удаление чата. Сумма лимита берётся из записей, поэтому смена цен и удаление модели из каталога прошлый расход не меняют. Новые методы хранилища `insertUsageRecord` и `sumUsage`.
1816
+ - Ядро: `createChatService({ limits })` и `ChatService.getSpendingStatus`. Проверка идёт перед отправкой, повтором, переделкой и правкой, до любых записей; исчерпание — `SpendingLimitReachedError`.
1817
+ - Сервер: `createChatHandler({ limits })`. Исчерпанный лимит — `429 {"error":{"kind":"spending_limit_reached","scope","period","resetsAt"}}`; новый адрес `GET /api/chat/:id/spending` (без сумм в долларах).
1818
+ - `@ykurilov/agent-chat-limits`: `createSpendingLimits({ storage, catalog, budget, period?, warnAt?, unpricedModels?, now?, logError? })` и `getSpending({ userId, chatId })` для страницы «тариф» (расход чата считается только среди записей этого человека). Своя модель без цены — понятная ошибка при создании; явно `unpricedModels: "ignore"` — такие ходы пишутся с нулевой стоимостью. `compactNow` лимит не проверяет: ручное сжатие остаётся доступным.
1819
+ - Хранилища: SQLite (миграция `0006`) и PostgreSQL (миграция `0005`) добавляют таблицу `usage_records`; миграции применяются при открытии хранилища сами, существующие данные не трогаются. Проверено на копии базы песочницы.
1820
+ - Браузер: `AgentChat` принимает `spendingLimits`. Договор скина версии 8: необязательный компонент `SpendingNotice({ id, state, text })` (`state` бывает `none`: область для диктора всегда на странице) и необязательные свойства `Composer.sendingBlocked` и `Composer.sendingBlockedReasonId` (для `aria-describedby`). Скин Тандема их рисует; при остановке фокус переходит на надпись и возвращается в поле, когда лимит снят. Отказ 429 не запускает переподключения: текст возвращается в поле (при правке сообщения редактор остаётся открытым с текстом и надписью `editErrors.spendingLimit`), показывается «Лимит на месяц исчерпан, обновится 1 ноября»; исчерпание снимается само в момент обновления.
1821
+ - Словарь надписей: `spendingWarning` и `spendingExhausted` (функции от сведений о лимите) и `editErrors.spendingLimit`.
1822
+
1823
+ Не входит в расход: веб-инструменты (`web-tools`, другой сервис), незаконченный шаг остановленного хода и ход, оборванный перезапуском сервера. Ход, начатый в пределах бюджета, доводится до конца; одновременные ходы могут вместе выйти за бюджет (проверка не резервирует деньги).
1824
+
1825
+ ### Как обновить приложение
1826
+
1827
+ Если лимиты не нужны, делать ничего не нужно, кроме двух случаев:
1828
+
1829
+ 1. **Только если вы реализовали `ChatStorage` сами:** добавьте `insertUsageRecord(record)` и `sumUsage(filter)` (запись расхода несёт `costUsd`, сумма возвращает `costUsd`) и прогоните `describeChatStorageContract` (в нём появился блок «записи расхода»). Хранилища SQLite и PostgreSQL из этого репозитория обновляются сами.
1830
+ 2. **Только если словарь надписей написан целиком** (не через `{ ...russianLabels, … }`): добавьте функции `spendingWarning` и `spendingExhausted` и надпись `editErrors.spendingLimit`. Значения — в `russianLabels`.
1831
+
1832
+ Чтобы включить лимиты:
1833
+
1834
+ 1. `pnpm add @ykurilov/agent-chat-limits`.
1835
+ 2. На сервере: `const limits = createSpendingLimits({ storage, catalog, budget: async ({ userId }) => ({ user: 5 }) })` и `createChatHandler({ …, limits })`. `storage` и `catalog` — те же, что у чата.
1836
+ 3. В браузере: `<AgentChat … spendingLimits />`. Скин свой — добавьте `SpendingNotice` (с `id`, состоянием `none` и `role="status"`) и учтите `Composer.sendingBlocked` и `sendingBlockedReasonId`; нет их — надписи не будет, но отправка остановится.
1837
+ 4. Проверка: с маленьким бюджетом чат сначала предупреждает, потом останавливает отправку и пишет дату обновления.
1838
+
1839
+ ## 0.8.0
1840
+
1841
+ ### Minor Changes
1842
+
1843
+ - a24c79d: **Ломает приложения:** у `ChatStorage` три новых обязательных метода (`listChats`, `renameChat`, `deleteChat`), у `AttachmentStorage` — один (`removeAllOfChat`), а у `ChatLabels` — 16 новых обязательных надписей `chatList*`. Приложения на готовых хранилищах и готовом словаре надписей ничего менять не должны; поломка только у своих реализаций.
1844
+
1845
+ Список чатов в боковой панели: создать чат, переименовать, удалить. Чат сам получает название по первому сообщению. Это расширение по желанию: приложение без `chatList` и без `ChatWorkspace` работает как раньше.
1846
+
1847
+ Что изменилось:
1848
+
1849
+ - `@ykurilov/agent-chat-core`: `createChatService` умеет `listChats(userId)`, `renameChat(userId, chatId, title)`, `deleteChat(userId, chatId)`. Название ставится по первому сообщению (до 60 знаков), вручную заданное не перезаписывается; длина названия до 100 знаков. Удаление настоящее: идущий ответ и сжатие останавливаются, затем убираются сообщения, записи сжатия, выбор веток, файлы вложений и сам чат. Чужой и несуществующий чат — одинаково `ChatNotFoundError`. Экспортируются `deriveChatTitle`, `normalizeChatTitle`, `InvalidChatTitleError`, тип `ChatListItem`. `Chat.title` необязательное. Набор проверок `describeChatStorageContract` расширен.
1850
+ - `@ykurilov/agent-chat-server`: `createChatHandler({ chatList: true })` включает три адреса: `GET {basePath}` (список), `PATCH {basePath}/:id` с телом `{ title }`, `DELETE {basePath}/:id`. Без `chatList` эти адреса отвечают 404. Чужой чат — 404, неверное название — 400. Идущий ответ удалению не мешает: он останавливается. `409` при удалении — только если чат уже удаляется или в этот миг запускается ход; пока чат удаляется, сообщение, сжатие и вложения в него тоже получают `409`. Переименование `409` не даёт.
1851
+ - `@ykurilov/agent-chat-storage-sqlite` и `@ykurilov/agent-chat-storage-postgres`: колонка `title` у чатов, методы `listChats`, `renameChat`, `deleteChat` (удаление в одной транзакции). Миграция сама заполняет названия старых чатов по их первому сообщению.
1852
+ - `@ykurilov/agent-chat-attachments`: `AttachmentStorage` получил `removeAllOfChat(chatId)`; `createAttachments` отдаёт ядру `removeChatFiles`, и файлы удаляются вместе с чатом. Набор `describeAttachmentStorageContract` расширен.
1853
+ - `@ykurilov/agent-chat-react`: компонент `ChatWorkspace` (список и открытый чат вместе), хук `useChatList`, у `AgentChat` необязательное `onBusyChange`. Договор скина версии 7: скин, который хочет работать с `ChatWorkspace`, дополнительно даёт `ChatWorkspace` и `ChatList` (тип `ChatListSkin`); договор самого чата не менялся. 16 новых надписей `chatList*` в словаре (`ChatLabels`).
1854
+ - `@ykurilov/agent-chat-skin-tandem`: `TandemChatWorkspace` и `TandemChatList`. На широком экране список слева, на узком — панель по кнопке «Чаты».
1855
+
1856
+ ### Как обновить приложение
1857
+
1858
+ Если список чатов не нужен, а хранилища и словарь надписей готовые, обновление ничего не требует.
1859
+
1860
+ Если у приложения своё `ChatStorage`:
1861
+
1862
+ 1. Добавьте методы `listChats(ownerId)`, `renameChat(chatId, title | null)` и `deleteChat(chatId)`. Список — только чаты владельца, самые недавно менявшиеся первыми; `deleteChat` убирает сообщения, записи сжатия, выбор веток и чат одной транзакцией. Точные правила — в общем наборе проверок.
1863
+ 2. Храните название чата (`Chat.title`, необязательное) и проведите свою миграцию.
1864
+ 3. Проверка: `describeChatStorageContract` из `@ykurilov/agent-chat-core/storage-contract` проходит.
1865
+
1866
+ Если у приложения своё `AttachmentStorage`: добавьте `removeAllOfChat(chatId): Promise<number>` (удалить все файлы чата, вернуть сколько) и прогоните `describeAttachmentStorageContract`.
1867
+
1868
+ Если у приложения свой полный словарь надписей (`ChatLabels` целиком, а не `{ ...russianLabels, … }`): добавьте 16 новых ключей, иначе `pnpm typecheck` упадёт. Простые строки: `chatListHeading` («Чаты»), `chatListNewChat` («Новый чат»), `chatListUntitled` («Новый чат»), `chatListLoading`, `chatListLoadFailed`, `chatListCreateFailed`, `chatListRenameFailed`, `chatListDeleteFailed`, `chatListRename`, `chatListRenameField`, `chatListDelete`, `chatListDeleteTitle`, `chatListDeleteConfirm`, `chatListDeleteCancel`. Функции от названия чата: `chatListRowMenu(title)` («Действия с чатом …») и `chatListDeleteBody(title)`.
1869
+ Было: `const labels: ChatLabels = { …прежние надписи }`. Стало: те же надписи плюс 16 новых; проще всего `{ ...russianLabels, …свои }`.
1870
+
1871
+ Чтобы включить список чатов:
1872
+
1873
+ 1. Сервер: `createChatHandler({ …, chatList: true })`. Базы SQLite и PostgreSQL обновятся сами при открытии.
1874
+ 2. Браузер: вместо `<AgentChat api chatId … />` — `<ChatWorkspace api="/api/chat" skin={tandemSkin} … />` (те же остальные свойства, кроме `chatId`). Открытый чат приложение может показывать в адресе: `initialChatId` и `onChatChange`.
1875
+ 3. Если скин свой: добавьте `ChatWorkspace` и `ChatList` по договору версии 7 (`ChatListSkin`). Скин Тандема обновляется вместе с пакетом.
1876
+ 4. Если приложение само создавало чат кнопкой «Новый чат», уберите её: «Новый чат» теперь в списке.
1877
+ 5. Проверка: `pnpm typecheck` (ловит недостающие методы хранилищ и надписи); в списке видны чаты пользователя, «Новый чат» открывает пустой чат, после первого сообщения чат получает название, переименование и удаление (с подтверждением) работают.
1878
+
1879
+ ## 0.7.0
1880
+
1881
+ ### Minor Changes
1882
+
1883
+ - b8f9e9f: **Ломает приложения со своими хранилищами и своими скинами:** ответ агента можно переделать, а отправленное сообщение исправить; все варианты сохраняются, между ними переключает «‹ 2/3 ›».
1884
+
1885
+ Что изменилось:
1886
+
1887
+ - Под ответом агента появилась кнопка «Переделать ответ»: агент отвечает на тот же вопрос заново, прежний ответ остаётся вариантом. Под сообщением собеседника появился карандаш «Изменить сообщение»: правка идёт на месте пузыря, отправляется новой веткой с новым ответом агента.
1888
+ - Когда у сообщения больше одного варианта, рядом стоит переключатель «‹ 2/3 ›». Выбранный вариант запоминается на сервере: после обновления страницы или на другом устройстве открывается он же.
1889
+ - Ничего не удаляется и не переписывается: журнал переписки только дописывается, выбор варианта — отдельная запись в новой таблице `branch_selections`.
1890
+ - Модель видит только текущую ветку: после правки она не знает прежнего текста, после переключения на прежний вариант — знает. Инструкция и инструменты в запросе не меняются, кэш модели ветки не задевают. Сжатие истории и поиск по старой переписке работают по текущей ветке (сводка действует на ветке, где она сделана).
1891
+ - Правка сообщения с файлами (пакет вложений): файлы остаются как были и переходят в новый вариант, меняется только текст; убрать или добавить файл при правке нельзя. Сообщение из одних файлов можно отправить и с пустым текстом. Файлы проходят ту же проверку сервера, что и при обычной отправке.
1892
+ - Пока идёт ответ, переключать, переделывать и править нельзя: сервер отвечает 409 `busy`, кнопки в экране неактивны. Если отказ всё же пришёл (ответ пошёл с другой вкладки), поле правки остаётся открытым с текстом собеседника и понятной ошибкой: текст не пропадает.
1893
+ - Новые адреса в `createChatHandler`: `GET /api/chat/:id/branches`, `POST /api/chat/:id/branch`, `POST /api/chat/:id/regenerate`, `POST /api/chat/:id/edit`; развилки приходят и в `GET /api/chat/:id` (поле `forks`). Новые методы службы: `selectBranch`, `regenerate`, `editMessage`, `getBranchForks`. Хук `useAgentChat` отдаёт `forks`, `selectBranch`, `canRegenerate`, `regenerate`, `canEdit`, `edit` (возвращает `Promise<EditOutcome>`: правка началась, не началась или сервер отказал).
1894
+ - Базы SQLite (миграция 0004) и PostgreSQL (миграция 0003) получают новую таблицу сами при первом запуске. Ничего запускать вручную не нужно.
1895
+ - В скине Тандема строка с карандашом и переключателем занимает прежний промежуток между сообщениями: высота сообщений не меняется.
1896
+
1897
+ ### Как обновить приложение
1898
+
1899
+ 1. Обновите все пакеты SDK одной командой: `pnpm update "@ykurilov/agent-chat-*" --latest`. Базы обновятся сами при первом запуске сервера.
1900
+ 2. **Если у приложения своё хранилище** (свой класс, реализующий `ChatStorage`): добавьте два метода и таблицу под них.
1901
+
1902
+ ```ts
1903
+ insertBranchSelection(record: StoredBranchSelection): Promise<void>; // дописать запись «выбран этот вариант»
1904
+ listBranchSelections(chatId: string): Promise<StoredBranchSelection[]>; // записи чата по порядку добавления
1905
+ ```
1906
+
1907
+ `StoredBranchSelection` — `{ id, chatId, messageId, selectedAt }` (оба времени — строка ISO). Записи только добавляются, не меняются и не удаляются. Тип и общий тест `describeChatStorageContract` берутся из `@ykurilov/agent-chat-core`; тест теперь проверяет и эти методы. Приложения на памяти, SQLite и PostgreSQL из наших пакетов ничего не меняют.
1908
+ 3. **Если у приложения свой скин** (объект `ChatSkin` не из `@ykurilov/agent-chat-skin-tandem`): договор скина теперь версии 6 (версия 5 — голосовой ввод и вложения, необязательные `Composer.voice`, `Composer.attachments`, `UserMessage.attachments`). Добавьте два компонента и новые свойства сообщений.
1909
+
1910
+ ```tsx
1911
+ const BranchSwitcher: ChatSkin["BranchSwitcher"] = ({ position, count, onPrevious, onNext }) => {
1912
+ const labels = useChatLabels();
1913
+ return (
1914
+ <div role="group" aria-label={labels.branchPosition(position, count)}>
1915
+ <button type="button" aria-label={labels.previousBranch} disabled={!onPrevious} onClick={onPrevious}>‹</button>
1916
+ {position}/{count}
1917
+ <button type="button" aria-label={labels.nextBranch} disabled={!onNext} onClick={onNext}>›</button>
1918
+ </div>
1919
+ );
1920
+ };
1921
+ const MessageEditor: ChatSkin["MessageEditor"] = ({ value, onValueChange, onSave, onCancel, canSave, error, attachments }) => {
1922
+ const labels = useChatLabels();
1923
+ return (
1924
+ <div>
1925
+ {attachments && <ul aria-label={attachments.label}>{attachments.items.map((item) => <li key={item.id}>{item.name}</li>)}</ul>}
1926
+ <textarea aria-label={labels.editMessage} value={value} onChange={(event) => onValueChange(event.target.value)} />
1927
+ {error && <p role="alert">{error}</p>}
1928
+ <button type="button" onClick={onCancel}>{labels.cancelEdit}</button>
1929
+ <button type="button" disabled={!canSave} onClick={onSave}>{labels.saveEdit}</button>
1930
+ </div>
1931
+ );
1932
+ };
1933
+ const skin = { ...mySkin, BranchSwitcher, MessageEditor };
1934
+ ```
1935
+
1936
+ `UserMessage` получает свойства `branchSwitcher?: ReactNode` (положить рядом с сообщением) и `onEdit?: () => void` (кнопка «Изменить сообщение»; когда правка недоступна, свойства нет). `AgentMessage` получает `branchSwitcher?: ReactNode` и `onRegenerate?: () => void` (кнопка «Переделать ответ», только у последнего законченного ответа). Свойства необязательны: скин без них не ломается, только не показывает кнопок. Пример — `skins/tandem/src` в репозитории SDK.
1937
+ 4. **Если словарь надписей написан целиком**, а не через `{ ...russianLabels, … }`: добавьте ключи `previousBranch` («Предыдущий вариант»), `nextBranch` («Следующий вариант»), `branchPosition(position, count)` («Вариант 2 из 3»), `regenerate` («Переделать ответ»), `editMessage` («Изменить сообщение»), `saveEdit` («Отправить»), `cancelEdit` («Отмена»), `editErrors` — `{ busy: "Ответ ещё идёт. Дождитесь его конца и отправьте снова", offline: "Нет связи с сервером. Текст сохранён в поле", rejected: "Сервер не принял правку. Текст сохранён в поле" }`.
1938
+ 5. **Если приложение само собирает ленту через `useAgentChat`**: подключите `forks` и `selectBranch`, как в `packages/react/src/agent-chat.tsx`. Готовый `AgentChat` ничего менять не просит.
1939
+ 6. Проверка: `pnpm typecheck` проходит; отправьте сообщение, нажмите «Переделать ответ» — под ответом появится «2/2», стрелка «‹» вернёт первый вариант, после обновления страницы выбор сохранится. Подробности — раздел «Ветки ответов и правка сообщений» в `docs/getting-started.md`.
1940
+ - 7c205c4: Новый пакет `@ykurilov/agent-chat-attachments`: вложения к сообщению. В поле ввода появляются скрепка, перетаскивание и вставка файлов из буфера, превью с удалением; в ленте у сообщения — миниатюры картинок и плашки файлов. Картинки, PDF и текстовые файлы; тип определяется по содержимому.
1941
+
1942
+ Что изменилось:
1943
+
1944
+ - `@ykurilov/agent-chat-attachments`: `createAttachments({ storage, limits? })`, хранилища файлов `createDiskAttachmentStorage({ directory })` и `createMemoryAttachmentStorage()`, `attachmentsInstructions`, общий набор проверок своего хранилища `describeAttachmentStorageContract` (подпуть `/storage-contract`). Подпуть `/react`: `attachmentsClient`. Пределы по умолчанию: 10 файлов, 20 МБ на файл, потолок извлечённого текста; меняются в `limits`.
1945
+ - Картинки уходят только моделям с `acceptsImages`; если модель не видит, человек узнаёт об этом до отправки. Текст PDF и текстовых файлов достаётся на сервере и идёт любой модели. Кэш модели не страдает: извлечённый текст не меняется от хода к ходу.
1946
+ - `@ykurilov/agent-chat-core`: `createChatHandler`/`createChatService` принимают необязательное `attachments`; меню моделей несёт `acceptsImages`; задание сжатия просит назвать прикреплённые файлы в сводке (оригиналы остаются). Без `attachments` файлы в сообщении отклоняются.
1947
+ - `@ykurilov/agent-chat-server`: новые адреса под адресом чата: `GET /attachments/settings`, `POST /:chatId/attachments`, `GET` и `DELETE /:chatId/attachments/:id`.
1948
+ - `@ykurilov/agent-chat-react`: `AgentChat` принимает необязательное `attachments`. Договор скина версии 5: `Composer` и `UserMessage` получают необязательное `attachments`, у моделей в `ModelChoiceView` появилось необязательное `acceptsImages`.
1949
+ - `@ykurilov/agent-chat-skin-tandem`: скрепка, превью, миниатюры и плашки файлов. Встроенный микрофон полного поля Тандема спрятан: голос — отдельная кнопка (пакет `voice`).
1950
+ - Разбор PDF идёт в отдельном потоке с потолком памяти и сроком: зависший или «раздутый» файл обрывается, сервер живёт дальше; текст собирается до потолка символов.
1951
+ - Лимиты на человека: `maxUnsentFiles` (30), `maxUnsentBytes` (200 МБ), `maxParallelUploads` (8), `maxParallelUploadsPerUser` (3); отказы 429 `too_many_unsent` и `busy`. Файлы с ошибкой в лимит не входят. Тело загрузки читается потоком без лишних копий. Поле ввода грузит файлы по два одновременно, остальные ждут в очереди, а на отказ `busy` повторяет несколько раз.
1952
+ - Диск чистит недописанные остатки (`.tmp` и `.bin` без описания) по возрасту; неотправленные файлы убираются через 24 часа, прикреплённые не трогаются. Просроченный файл возвращается в поле с пометкой «загрузите снова».
1953
+ - Файл помечается «прикреплён» только после записи сообщения (`confirmUserParts` в контракте `MessageAttachments`; `acceptUserParts` файлы не помечает). Отказ отправки возвращает текст и файлы в поле, повторный клик не очищает его раньше времени. Контракт `AttachmentsController` в `@ykurilov/agent-chat-react`: вместо `takeFiles` — `readFiles`, `markSent`, `restoreFiles`; `useAgentChat.submit` возвращает признак «принято».
1954
+ - Текстовые логи с цветовыми кодами терминала (ANSI) принимаются как текст, коды убираются. Закрывающий тег `</attachment>` в тексте файла обезвреживается без учёта регистра.
1955
+ - `MessageAttachments.instructions`: ядро само дописывает инструкцию про вложения к инструкции агента (один раз, дубль не появляется). Вручную `attachmentsInstructions` добавлять больше не нужно, но и не вредно.
1956
+ - `@ykurilov/agent-chat-skin-tandem`: пока грузится список моделей, в поле со вложениями нет пустой кнопки «Выберите модель».
1957
+ - Схема базы чата не меняется, миграции не нужны.
1958
+
1959
+ ### Как обновить приложение
1960
+
1961
+ Если вложения не нужны, обновление ничего не требует: без `attachments` чат работает как раньше.
1962
+
1963
+ Чтобы включить вложения:
1964
+
1965
+ 1. `pnpm add @ykurilov/agent-chat-attachments`.
1966
+ 2. На сервере: `const attachments = createAttachments({ storage: createDiskAttachmentStorage({ directory: "./data/attachments" }) })`, передайте `attachments` в `createChatHandler` и инструкцию агенту про вложения ядро добавляет само. Папку с файлами включите в резервные копии вместе с базой.
1967
+ 3. В браузере: `<AgentChat … attachments={attachmentsClient} />` (`attachmentsClient` из `@ykurilov/agent-chat-attachments/react`).
1968
+ 4. Если скин свой: `Composer` получает `attachments` (скрепка, превью, `notice`), `UserMessage` — `attachments` (миниатюры и плашки). Нет свойства — ничего про вложения не рисовать. Свой скин на `@ykurilov/agent-chat-skin-tandem` обновляется вместе с пакетом.
1969
+ 5. Проверка: `pnpm typecheck`; в поле ввода есть скрепка, картинка и PDF прикрепляются, в ленте видны миниатюра и плашка, агент отвечает по содержимому.
1970
+
1971
+ ## 0.6.0
1972
+
1973
+ ### Minor Changes
1974
+
1975
+ - 5252793: **Ломает приложения со своими хранилищами и своими скинами:** длинная переписка теперь сжимается сама, а у агента появился инструмент «поиск по старой переписке».
1976
+
1977
+ Что изменилось:
1978
+
1979
+ - Когда модель видит больше 200 тысяч токенов (у моделей с маленьким окном — 80% окна), ядро в фоне после ответа заменяет старую часть переписки короткой сводкой и оставляет свежие ~20 тысяч токенов как есть. Сообщения из базы не удаляются. В ленте на месте сжатия появляется строка «История сжата», по клику открывается сводка. Сжатие включено по умолчанию; `compaction: false` в `createChatHandler` (или `createChatService`) его выключает, `compaction: { thresholdTokens, tailTokens, summaryCeilingTokens, model, instruction }` настраивает.
1980
+ - Новый пакет `@ykurilov/agent-chat-history-search`: инструмент `search_earlier_conversation`, который ищет точные детали в оригиналах старой переписки. Подключается по желанию.
1981
+ - Новый адрес `GET /api/chat/:id/compactions` (в `createChatHandler`), новый метод службы `compactNow`, набор инструментов агента получает `chatId` хода.
1982
+ - «Стоп» обрывает ожидание сжатия сразу (само сжатие доделывается в фоне). `chat.flush()` при остановке сервера ждёт идущие сжатия до `compaction.flushWaitMs` (30 секунд по умолчанию) и отменяет остальные без записи полусводки.
1983
+ - Разделитель появляется без перезагрузки надёжно: сервер ставит признак «идёт сжатие» до конца потока ответа, а экран опрашивает `/compactions`, пока признак стоит (до 12 минут). `/compactions` стал лёгким: читает связи сообщений, а не тексты с выводами инструментов.
1984
+ - Сводку пишет самый малый уровень рассуждений модели (или без них, где можно), а потолок вывода имеет запас, так что рассуждения не обрывают сводку по длине. Пауза после сбоя теперь действует и на исход «нечего сжимать».
1985
+ - Поиск нормализует текст каждого сообщения один раз за вызов.
1986
+ - Дедупликация сжатий живёт в памяти процесса, как и реестр идущих ответов: при нескольких копиях сервера возможно двойное сжатие и двойной расход токенов.
1987
+ - Базы SQLite и PostgreSQL получают новую таблицу и новые поля сами при первом запуске. Ничего запускать вручную не нужно.
1988
+
1989
+ ### Как обновить приложение
1990
+
1991
+ 1. Обновите все пакеты SDK одной командой: `pnpm update "@ykurilov/agent-chat-*" --latest`. Базы обновятся сами при первом запуске сервера.
1992
+ 2. **Если у приложения своё хранилище** (свой класс, реализующий `ChatStorage`): добавьте три метода.
1993
+
1994
+ ```ts
1995
+ insertCompaction(record: StoredCompaction): Promise<void>; // записать отметку сжатия
1996
+ listCompactions(chatId: string): Promise<StoredCompaction[]>; // отметки чата по порядку добавления
1997
+ listMessageLinks(chatId: string): Promise<MessageLink[]>; // { id, parentId, createdAt } сообщений чата, в порядке listMessages
1998
+ ```
1999
+
2000
+ Типы `StoredCompaction`, `MessageLink` и общий тест `describeChatStorageContract` берутся из `@ykurilov/agent-chat-core`; тест теперь проверяет и эти методы.
2001
+ Приложения на памяти, SQLite и PostgreSQL из наших пакетов ничего не меняют.
2002
+ 3. **Если у приложения свой скин** (объект `ChatSkin` не из `@ykurilov/agent-chat-skin-tandem`): добавьте компонент `CompactionDivider`, договор скина теперь версии 4.
2003
+
2004
+ ```tsx
2005
+ const CompactionDivider: ChatSkin["CompactionDivider"] = ({ summary, open, onOpenChange }) => {
2006
+ const labels = useChatLabels();
2007
+ return (
2008
+ <div>
2009
+ <button type="button" aria-expanded={open} onClick={() => onOpenChange(!open)}>
2010
+ {labels.historyCompacted}
2011
+ </button>
2012
+ {open && (
2013
+ <div>
2014
+ <p>{labels.historyCompactedHint}</p>
2015
+ <pre>{summary}</pre>
2016
+ </div>
2017
+ )}
2018
+ </div>
2019
+ );
2020
+ };
2021
+ const skin = { ...mySkin, CompactionDivider };
2022
+ ```
2023
+
2024
+ `summary` — сводка в Markdown; свёрнутый разделитель — строка «История сжата». Скин Тандема обновляется вместе с пакетом.
2025
+ 4. **Если словарь надписей написан целиком**, а не через `{ ...russianLabels, … }`: добавьте ключи `historyCompacted` («История сжата») и `historyCompactedHint`.
2026
+ 5. Чтобы агент мог искать в сжатой переписке (по желанию): `pnpm add @ykurilov/agent-chat-history-search`, затем в `agent` обработчика добавьте `historySearchInstructions` в `instructions` и `...historySearch(model)` в `tools`; шаги и код — в разделе «Сжатие истории и поиск по старой переписке» файла `docs/getting-started.md`.
2027
+ 6. Проверка: `pnpm typecheck` проходит, чат открывается и отвечает. Кэш модели после первого сжатия обнуляется один раз (запрос после него дороже обычного), это ожидаемо.
2028
+
2029
+ ## 0.5.0
2030
+
2031
+ ### Minor Changes
2032
+
2033
+ - 62027c6: Ломает приложения: в поле ввода появился выбор модели и уровня рассуждений, поэтому «какой моделью отвечать» теперь решает не сервер приложения на каждый ход, а пользователь в чате. Настройка `resolveModel` заменена на `defaultChoice` (модель для нового чата).
2034
+
2035
+ Что нового простыми словами:
2036
+
2037
+ - Стандартный набор моделей: DeepSeek 4.1 Flash, GLM 5.3 Flash, GPT 6 Luna, Gemini 3.8 Flash. Провайдеры: Fireworks, DeepSeek, Together и новый OpenRouter (ключ `OPENROUTER_API_KEY`, среди хозяев модели выбирается самый быстрый).
2038
+ - В поле ввода одно меню моделей (провайдер → модель) и рядом выбор уровня рассуждений. Уровни свои у каждой модели: у DeepSeek «Выкл/Вкл», у GLM от «Низкий», у GPT и Gemini несколько уровней. У GPT рассуждения приходят зашифрованными: человеку виден только пересказ. На телефоне меню плоское («модель · провайдер»).
2039
+ - Выбор запоминается в чате. Модель можно сменить посреди чата: рассуждения прежней модели в запрос новой не уходят, но в базе и на экране остаются. Если сохранённой модели больше нет в каталоге, чат открывается на выборе по умолчанию, сообщения не меняются.
2040
+ - `extendCatalog` умеет больше: `removeProviders` (провайдер уходит, модель без провайдеров уходит вместе с ним) и `pinModelVersions` (закрепить версию модели у провайдера: `{ "gpt-6-luna": { openrouter: "openai/gpt-6-luna-2026-09-30" } }`). Ошибочная правка — ошибка при создании каталога.
2041
+ - Новые адреса сервера: `GET /api/chat/models` (список для меню, без ключей) и `PUT /api/chat/:id/model` (записать выбор чата).
2042
+ - Скин Тандема рисует `Composer` варианта `extended`. Договор скина стал версии 3: `Composer` получает необязательное `modelChoice`; свой скин без него работает как раньше. В `@ykurilov/agent-chat-react` есть хук `useModelChoice`.
2043
+ - Кэш: у Gemini через OpenRouter метка кэша теперь стоит и на последнем сообщении истории (скользящая точка, на следующем ходе переезжает на новое), поэтому кэшируется вся переписка, а не только инструкция. Для своего провайдера то же задаётся полем `lastMessageProviderOptions` в `requestOptions`.
2044
+ - Профиль модели получил отметку `toolResultImageProviders`: провайдеры, через которых модель видит картинку в результате инструмента (по живым замерам). У GPT 6 Luna и Gemini 3.8 Flash это `["openrouter"]`, у GLM 5.3 Flash и DeepSeek 4.1 Flash отметки нет. Ядро отметку не читает; по ней веб-инструменты решают, выдавать ли `page_screenshot`.
2045
+ - Если ключа выбранного провайдера временно нет, ответ идёт на запасной модели, но выбор пользователя в чате не перезаписывается: когда ключ вернётся, чат снова отвечает выбранной моделью.
2046
+ - Хранилища: у чата новое поле `reasoning` (уровень рассуждений). SQLite: колонка `chats.reasoning`, PostgreSQL: колонка `agent_chat.chats.reasoning`; миграции применяются сами при открытии базы, данные не теряются.
2047
+
2048
+ ### Как обновить приложение
2049
+
2050
+ 1. Сервер (там, где `createChatHandler` или `createChatService`). Ищите `resolveModel`.
2051
+
2052
+ Было:
2053
+
2054
+ ```ts
2055
+ createChatHandler({
2056
+ // …
2057
+ resolveModel: async () => ({ modelId: "deepseek-4.1-flash", providerId: "deepseek" }),
2058
+ catalog: standardCatalog,
2059
+ });
2060
+ ```
2061
+
2062
+ Стало:
2063
+
2064
+ ```ts
2065
+ createChatHandler({
2066
+ // …
2067
+ // Только для нового чата; дальше модель выбирает пользователь. Можно не задавать:
2068
+ // тогда берётся первая модель каталога у первого провайдера с ключом.
2069
+ defaultChoice: { modelId: "deepseek-4.1-flash", providerId: "deepseek" },
2070
+ catalog: standardCatalog,
2071
+ });
2072
+ ```
2073
+
2074
+ Раньше `resolveModel` мог выбирать модель по пользователю или чату. Теперь это делает пользователь в меню; ограничить выбор можно каталогом (`extendCatalog(standardCatalog, { removeProviders, removeModels, addModels, addProviders, pinModelVersions })`) и ключами на сервере: в меню попадают только провайдеры, чей ключ есть в окружении.
2075
+ 2. Ключи. Хотите GPT 6 Luna, Gemini 3.8 Flash или GLM через OpenRouter — добавьте `OPENROUTER_API_KEY` в окружение сервера. Без него этих моделей в меню не будет; остальное работает как раньше.
2076
+ 3. Свой словарь надписей. Если вы задали `ChatLabelsProvider labels={…}` не через `{ ...russianLabels, … }`, добавьте новые ключи: `reasoningLevels` (названия уровней: `off`, `on`, `minimal`, `low`, `medium`, `high`, `max`), `modelChoiceFailed` («Не удалось сменить модель») и `modelMenuFailed` («Список моделей не загрузился»). Через `{ ...russianLabels, … }` ничего делать не нужно.
2077
+ 4. Свой скин. Договор версии 3: `Composer` получает необязательное `modelChoice` (`providers`, `current`, `onChange`, `reasoningLevels`, `reasoning`, `onReasoningChange`, `error`). Не рисуете его — выбора модели в чате не будет, всё остальное работает. Подробно: `specs/003-models-and-picker/contracts/skin.md`. Скин Тандема (`tandemSkin`) обновляется вместе с пакетом, править нечего.
2078
+ 5. Своё хранилище (только если вы реализовали `ChatStorage` сами). Добавьте необязательное поле `reasoning?: string` в чат: `createChat` принимает и сохраняет его, `getChat` отдаёт (без значения — поля нет), `updateChatModel(chatId, { modelId, providerId, reasoning? })` записывает его целиком и без `reasoning` стирает сохранённый уровень. Наборы проверок `describeChatStorageContract` из `@ykurilov/agent-chat-core/storage-contract` проверяют это. Хранилища SQLite и PostgreSQL из SDK обновляются сами.
2079
+ 6. Проверка: `pnpm typecheck` проходит; в браузере в поле ввода есть меню моделей и выбор рассуждений; выбор остаётся после обновления страницы; сообщение отправляется и ответ приходит по частям.
2080
+ 7. Только если вы уже ставили `@ykurilov/agent-chat-web-tools` из main до этого выпуска: опция `screenshotProviders` убрана. Снимок страницы выдаётся по отметке `toolResultImageProviders` в профиле модели.
2081
+
2082
+ Было:
2083
+
2084
+ ```ts
2085
+ const webTools = createWebTools({ ...firecrawl, screenshotProviders: ["openrouter"] });
2086
+ ```
2087
+
2088
+ Стало:
2089
+
2090
+ ```ts
2091
+ const webTools = createWebTools(firecrawl);
2092
+ // Своя модель, у которой картинка из инструмента доходит через провайдера: добавьте отметку в профиль.
2093
+ // extendCatalog(standardCatalog, { addModels: [{ ...профиль, toolResultImageProviders: ["openrouter"] }] })
2094
+ ```
2095
+ - b059fcc: Переписку теперь можно хранить в PostgreSQL, а чат подключается к Next.js. Хранилища проверяются одним и тем же набором тестов.
2096
+
2097
+ - Новый пакет `@ykurilov/agent-chat-storage-postgres`: `createPostgresChatStorage({ connectionString })`. Таблицы лежат в отдельной схеме `agent_chat`, миграции применяются сами при создании, два сервера на старте друг другу не мешают.
2098
+ - В `@ykurilov/agent-chat-server` новая точка входа `@ykurilov/agent-chat-server/next`: `createNextRouteHandlers(chatHandler)` для файла маршрута App Router.
2099
+ - В `@ykurilov/agent-chat-core` набор проверок `describeChatStorageContract` дополнен: порядок сообщений при равном времени, изоляция чатов, очень длинный ответ, пустые поля. Хранилище в памяти для тестов остаётся в ядре, отдельного пакета нет.
2100
+ - В `@ykurilov/agent-chat-storage-sqlite` сообщения с одинаковым временем создания теперь читаются в порядке записи (раньше порядок между ними был не определён). Порядок держится на новой колонке `seq` в таблице `messages`, а не на внутреннем номере строки SQLite, который `VACUUM` вправе поменять. Миграция применяется сама при открытии базы: старые сообщения получают `seq` в том порядке, в каком лежали, данные не теряются.
2101
+
2102
+ ### Как обновить приложение
2103
+
2104
+ Только если вы берёте PostgreSQL или Next.js. Остальным обновление ничего не требует: SQLite и Hono работают как раньше, миграция SQLite (колонка `seq`) применится сама при первом запуске, делать с базой ничего не нужно.
2105
+
2106
+ 1. PostgreSQL вместо SQLite. В `server/index.ts` замените создание хранилища:
2107
+
2108
+ Было:
2109
+
2110
+ ```ts
2111
+ const storage = createSqliteChatStorage({ filePath: "chat.sqlite" });
2112
+ // при остановке: storage.close()
2113
+ ```
2114
+
2115
+ Стало:
2116
+
2117
+ ```ts
2118
+ import { createPostgresChatStorage } from "@ykurilov/agent-chat-storage-postgres";
2119
+
2120
+ const storage = await createPostgresChatStorage({ connectionString: process.env.DATABASE_URL! });
2121
+ // при остановке: await chat.flush(); await storage.close()
2122
+ ```
2123
+
2124
+ Адрес базы положите в `DATABASE_URL`. Пользователю базы нужно право создавать схему и таблицы. Переписку из SQLite пакет сам не переносит.
2125
+ 2. Next.js. Создайте `app/api/chat/[[...path]]/route.ts` с `createNextRouteHandlers` и строками `export const dynamic = "force-dynamic"` и `export const runtime = "nodejs"`; `basePath` обработчика чата должен совпадать с путём маршрута. Нужен долгоживущий сервер, не serverless. Пример и подробности: `docs/getting-started.md`, раздел «Next.js».
2126
+ 3. Проверка: `pnpm typecheck` проходит, чат отвечает по частям, «Остановить» работает.
2127
+ - 61717c6: У агента появились веб-инструменты: поиск в интернете, чтение страницы и скриншот страницы. Они вынесены в новый необязательный пакет; приложение, которому они не нужны, его не ставит и ничего не получает.
2128
+
2129
+ - Новый пакет `@ykurilov/agent-chat-web-tools`: `createWebTools({ search, reader, screenshots? })` и готовые сервисы на Firecrawl `createFirecrawlServices({ apiKey, baseUrl?, headers? })` из подпути `@ykurilov/agent-chat-web-tools/firecrawl`. Сервис заменяется без правки инструментов (интерфейсы `WebSearchService`, `PageReaderService`, `PageScreenshotService`). Ключ и адрес сервиса остаются на сервере; `baseUrl` и `headers` позволяют идти через прокси. Подписи вызовов для ленты — `withWebToolTitles(russianLabels)` из подпути `@ykurilov/agent-chat-web-tools/labels`.
2130
+ - Скриншот получают только модели с `acceptsImages: true`, у которых в профиле отмечено, что через выбранного провайдера они видят картинку в результате инструмента (`toolResultImageProviders`, см. запись про модели). Отмеченные в стандартном наборе: GPT 6 Luna и Gemini 3.8 Flash через OpenRouter.
2131
+ - В `@ykurilov/agent-chat-core` `agent.tools` может быть функцией от выбранной модели: `(model: { profile, providerId }) => ToolSet`. Так набор инструментов зависит от модели. Обычный набор работает как раньше; экспортируются типы `AgentToolsSource` и `AgentToolsModel`.
2132
+
2133
+ ### Как обновить приложение
2134
+
2135
+ Только если нужны веб-инструменты. Остальным обновление ничего не требует.
2136
+
2137
+ 1. Поставьте пакет той же версии, что остальные: `pnpm add @ykurilov/agent-chat-web-tools`.
2138
+ 2. На сервере (там, где `createChatHandler`) добавьте инструменты и инструкцию к ним. Ключ Firecrawl — в переменной окружения сервера, не в браузерной части:
2139
+
2140
+ ```ts
2141
+ import { createWebTools, webToolsInstructions } from "@ykurilov/agent-chat-web-tools";
2142
+ import { createFirecrawlServices } from "@ykurilov/agent-chat-web-tools/firecrawl";
2143
+
2144
+ const firecrawl = createFirecrawlServices({ apiKey: process.env.FIRECRAWL_API_KEY!, baseUrl: process.env.FIRECRAWL_BASE_URL });
2145
+ const webTools = createWebTools(firecrawl);
2146
+
2147
+ createChatHandler({
2148
+ // …
2149
+ agent: {
2150
+ instructions: `${appInstructions}\n${webToolsInstructions}`,
2151
+ tools: (model) => ({ ...appTools, ...webTools(model) }),
2152
+ },
2153
+ });
2154
+ ```
2155
+
2156
+ 3. В браузерной части подключите подписи: `<ChatLabelsProvider labels={withWebToolTitles(russianLabels)}>` вокруг `AgentChat` (`withWebToolTitles` — из `@ykurilov/agent-chat-web-tools/labels`).
2157
+ 4. Проверьте: `pnpm typecheck`; спросите агента «Найди в интернете последнюю версию Node.js» — в ленте появятся строки «Поиск в интернете: готово» и «Чтение страницы: готово», в ответе — ссылка на источник.
2158
+
2159
+ ## 0.4.0
2160
+
2161
+ ### Minor Changes
2162
+
2163
+ - ad8cf59: **Ломает приложения:** из `@ykurilov/agent-chat-core` убраны 21 имя, которыми приложения не пользовались, и два параметра `fetch`/`now`.
2164
+
2165
+ Что изменилось: идущий ответ пишется в базу не после каждого куска, а не чаще раза в 0,5 секунды. Длинные ответы больше не нагружают базу. При внезапном падении сервера теряется не больше 0,5 с текста, при штатной остановке — ничего, если вызвать новый метод `flush()` (у обработчика `createChatHandler` из `@ykurilov/agent-chat-server` он называется `chat.flush()`, у прямого `createChatService` — `service.flush()`). Ещё `registry` в `createChatService` стал необязательным: без него ядро создаёт его само.
2166
+
2167
+ Убраны из публичного входа (были внутренними деталями): `createModelCatalog`, `MissingProviderKeyError`, `resolveLanguageModel`, `buildModelRequest`, `ModelRequest`, `createReasoningTimer`, `ReasoningTimer`, `DEFAULT_MAX_STEPS`, `runAgentTurn`, `AgentTurnInput`, `classifyProviderError`, `createTransientRetryMiddleware`, `transientRetryMiddleware`, `TransientRetryOptions`, `FIRST_CHUNK_TIMEOUT_MS`, `FirstChunkTimeoutError`, `isTransientProviderError`, `TRANSIENT_RETRY_DELAYS_MS`, `MAX_MESSAGE_LENGTH`, `ChatHistory`, `ChatServiceOptions`. Из настроек `createChatService` убран `fetch`.
2168
+
2169
+ ### Как обновить приложение
2170
+
2171
+ 1. Найдите использование убранных имён: `grep -rnE "createModelCatalog|MissingProviderKeyError|resolveLanguageModel|buildModelRequest|createReasoningTimer|DEFAULT_MAX_STEPS|runAgentTurn|classifyProviderError|TransientRetry|transientRetryMiddleware|FIRST_CHUNK_TIMEOUT_MS|FirstChunkTimeoutError|isTransientProviderError|TRANSIENT_RETRY_DELAYS_MS|MAX_MESSAGE_LENGTH|ChatHistory|ChatServiceOptions" src server`. Если ничего не нашлось, шаг 2 не нужен.
2172
+ 2. Для найденного:
2173
+ - Свой каталог моделей: вместо `createModelCatalog(...)` используйте `extendCatalog(standardCatalog, ...)`.
2174
+ - Тип настроек службы: вместо `ChatServiceOptions` пишите `Parameters<typeof createChatService>[0]`.
2175
+ - Остальные имена были внутренними, замены нет: уберите их использование. Свои повторы и ход агента вызывать напрямую больше нельзя, только через `createChatService`.
2176
+ 3. Если вы передавали `fetch` в `createChatService`, уберите его: для подмены сети в тестах подставляйте свою модель в каталог.
2177
+ 4. Штатная остановка сервера (по желанию, но рекомендуется): после того как сервер перестал принимать запросы, и до закрытия хранилища дождитесь дозаписи ответов. Если чат подключён через `createChatHandler` — вызывайте `await chat.flush()` (подробности в записи для `@ykurilov/agent-chat-server`). Если приложение работает напрямую с `createChatService` — `await service.flush()`.
2178
+
2179
+ Было:
2180
+
2181
+ ```ts
2182
+ function shutdown() {
2183
+ server.close();
2184
+ closeStorage();
2185
+ process.exit(0);
2186
+ }
2187
+ ```
2188
+
2189
+ Стало (для прямого `createChatService`; с `createChatHandler` вместо `service` пишите `chat`):
2190
+
2191
+ ```ts
2192
+ async function shutdown() {
2193
+ server.close();
2194
+ await service.flush();
2195
+ closeStorage();
2196
+ process.exit(0);
2197
+ }
2198
+ ```
2199
+
2200
+ 5. Проверка: `pnpm typecheck` проходит, длинный ответ в чате идёт до конца, а после перезапуска сервера посреди ответа написанный текст на месте с отметкой «Ответ прерван».
2201
+
2202
+ ## 0.3.0
2203
+
2204
+ ### Minor Changes
2205
+
2206
+ - a95b2d0: Появился третий поставщик модели DeepSeek 4.1 Flash — Together AI (`providerId: "together"`). Он входит в `standardCatalog` рядом с `deepseek` и `fireworks`; рассуждения и вызовы инструментов проверены на нём вживую.
2207
+
2208
+ Обновление ничего не требует: приложения, которые работают с `deepseek` или `fireworks`, продолжают работать как раньше.
2209
+
2210
+ ### Как подключить Together (по желанию)
2211
+
2212
+ 1. Положите ключ Together AI в переменную окружения сервера `TOGETHER_API_KEY` (в `.env` рядом с `DEEPSEEK_API_KEY` и `FIREWORKS_API_KEY`). Ключ не пишите в код и не отдавайте браузеру.
2213
+ 2. В `resolveModel` (там, где вы создаёте `createChatHandler`) верните нужного поставщика:
2214
+
2215
+ ```ts
2216
+ resolveModel: async () => ({ modelId: "deepseek-4.1-flash", providerId: "together" }),
2217
+ ```
2218
+
2219
+ 3. Чат, начатый у другого поставщика, продолжается у Together без правок: рассуждения чужого поставщика в запрос не попадают.
2220
+ 4. Проверка: `pnpm typecheck` проходит, чат отвечает, а в журнале сервера нет «Нет ключа провайдера … TOGETHER_API_KEY».
2221
+
2222
+ Если приложение проверяет, что браузерная сборка не содержит адресов провайдеров, добавьте в эту проверку `api.together.xyz` и `TOGETHER_API_KEY`.
2223
+
2224
+ ## 0.2.0
2225
+
2226
+ ### Minor Changes
2227
+
2228
+ - ea50099: Первый выпуск Agent Chat SDK на складе GitHub Packages. Все пять пакетов теперь выходят вместе и
2229
+ с одной версией: «SDK 0.2.0» значит, что каждый пакет `@ykurilov/agent-chat-*` имеет версию 0.2.0.
2230
+
2231
+ Что изменилось по сравнению с тем, как пакеты подключали раньше:
2232
+
2233
+ - Пакеты приходят собранными (JavaScript и типы), а не исходниками TypeScript. Голый `node`
2234
+ запускает серверные пакеты без `tsx`.
2235
+ - Скин Тандема (`@ykurilov/agent-chat-skin-tandem`) теперь тоже пакет. Раньше папку `skins/tandem/src`
2236
+ копировали в приложение; копию можно удалить и подключить пакет.
2237
+ - Дизайн-система Тандем ставится вместе со скином как `@ykurilov/tandem-ui` (в коде остаётся
2238
+ импорт `@tandem/ui`).
2239
+
2240
+ ### Как обновить приложение
2241
+
2242
+ Если приложение подключало пакеты из этого репозитория без склада (ссылкой на папку или копией),
2243
+ переведите его на склад:
2244
+
2245
+ 1. В корне приложения в `.npmrc` оставьте одну строку:
2246
+
2247
+ ```ini
2248
+ @ykurilov:registry=https://npm.pkg.github.com
2249
+ ```
2250
+
2251
+ 2. Поставьте пакеты: `pnpm add @ykurilov/agent-chat-core @ykurilov/agent-chat-server @ykurilov/agent-chat-storage-sqlite @ykurilov/agent-chat-react`.
2252
+ 3. Если использовали скин Тандема из копии:
2253
+ - удалите папку с копией скина (например, `src/chat-skin`);
2254
+ - `pnpm add @ykurilov/agent-chat-skin-tandem "@tandem/ui@npm:@ykurilov/tandem-ui@^0.3.0"`;
2255
+ - в коде замените импорт `./chat-skin` на `@ykurilov/agent-chat-skin-tandem`;
2256
+ - в главном CSS-файле замените `@source` на папку скина на
2257
+ `@source "../node_modules/@ykurilov/agent-chat-skin-tandem/dist";`
2258
+ (путь считайте от CSS-файла до корня приложения).
2259
+ 4. Проверка: `pnpm typecheck`, сборка приложения, чат открывается и отвечает.