@ykurilov/agent-chat-server 0.0.0-stage → 0.56.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,1784 @@
1
+ # @ykurilov/agent-chat-server
2
+
3
+ ## 0.56.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [24c313e]
8
+ - @ykurilov/agent-chat-core@0.56.0
9
+
10
+ ## 0.55.0
11
+
12
+ ### Patch Changes
13
+
14
+ - Updated dependencies [51f7b38]
15
+ - Updated dependencies [cdc7dd7]
16
+ - @ykurilov/agent-chat-core@0.55.0
17
+
18
+ ## 0.54.0
19
+
20
+ ### Patch Changes
21
+
22
+ - Updated dependencies [fa90219]
23
+ - @ykurilov/agent-chat-core@0.54.0
24
+
25
+ ## 0.53.0
26
+
27
+ ### Patch Changes
28
+
29
+ - Updated dependencies [b6a7786]
30
+ - @ykurilov/agent-chat-core@0.53.0
31
+
32
+ ## 0.52.0
33
+
34
+ ### Patch Changes
35
+
36
+ - @ykurilov/agent-chat-core@0.52.0
37
+
38
+ ## 0.51.0
39
+
40
+ ### Patch Changes
41
+
42
+ - Updated dependencies [41b1726]
43
+ - @ykurilov/agent-chat-core@0.51.0
44
+
45
+ ## 0.50.0
46
+
47
+ ### Patch Changes
48
+
49
+ - @ykurilov/agent-chat-core@0.50.0
50
+
51
+ ## 0.49.0
52
+
53
+ ### Minor Changes
54
+
55
+ - 084cef7: **Ломает приложения (типы и своё хранилище сценариев):** у типа поля сценария `FlowFieldType` появилось значение `"boolean"`, у источника
56
+ расхода `UsageSource` — `"first_message"`, у `SteerResult.status` — `"held"`. Исчерпывающий `switch` по ним перестанет
57
+ собираться. Своё хранилище сценариев (`FlowStore`) должно реализовать новый `renewLease()` и третий аргумент `setDue`.
58
+
59
+ Сценарии для приложений: программа по дням (ночь, касания, рамки дня, «срок вышел»), своя служба чата и доставка с
60
+ исходами, пауза сотрудника, конец разговора с итогом, карточка и пояс человека в базе приложения, попутные задачи.
61
+ Проверка готового ответа: «чужие слова» и «выдуманные ссылки». Подробно — `docs/flows.md`, `docs/reply-check.md`,
62
+ раздел 0.49.0 в `CHANGES.md`.
63
+
64
+ - **flows**: формат сценария версии 2 (`schedule`, `nudges`, таймер `state_days`, поле `boolean`, `sideTasks`,
65
+ `afterEnd`; версия 1 читается сама); `attach({ portFor, deliver })`, `admit`, `pause`/`resume`/`noteStaffReply`,
66
+ `reportDelivery`, `onFinish`, `clientCard`, `people`, `settled`, `now`; причина перехода `reason`; перенос чата
67
+ через `loadFlow` (`stateId`, `startedAt`, `enteredAt`); заголовки судьи `judge.openrouter.headers`; встроенная задача
68
+ «узнать город»; `checkFlow(flow, catalog, { checks })` и `FlowAppChecks` из `/format`; в редакторе панели дней,
69
+ касаний, рамок и попутных задач, `catalog.fields`, кнопка «К началу следующего дня» (`advanceToNextDay`). Новые
70
+ таблицы `flow_people`, `flow_side_tasks` и колонки `flow_chats` создаются сами.
71
+ - **core**: `replyChecks` (список или `{ checks, web: "replace" | "hold" }`), `now`, `markUndelivered`, ответы
72
+ `beforeTurn` `{ hold: true }` и `{ reply: { text } }`, `deliverNotices({ timeZone, awaitAnswer, usageSource })`,
73
+ источник расхода `first_message`.
74
+ - **flows, надёжность**: обход таймеров до 5 ходов одновременно; копия продлевает аренду чата перед ходом и во время
75
+ него. В `FlowStore` новые `renewLease()` и `setDue(…, { unlessDelivery })`. Недоставленное касание (`failed`,
76
+ `window_closed`) не засчитывается — тот же шаг через свой срок от отказа; в `FlowRun` новое поле `touch`. «Окно
77
+ закрыто» снимает любое сообщение человека; «не завершил» считает тишину и от конца паузы сотрудника; пока ждёт
78
+ повтор доставки, срок не затирается и новых ходов нет.
79
+ - **server**: `createChatServer` передаёт службе `replyChecks` и `now`.
80
+ - **messengers**: `onAnswerDelivery` — исход доставки каждого ответа; закрытое окно ВК (950) —
81
+ `MessengerWindowClosedError` и исход `window_closed`; с `replyChecks` уходит только проверенный текст.
82
+ - **web-tools**: `createPersonLinkTool` — инструмент `person_link_view`, агент смотрит ссылку, которую прислал человек
83
+ (адрес из почты ссылкой не считается).
84
+ - **reply-check** (новый пакет): `foreignWords` и `unknownLinks`; ответ из одной выдуманной ссылки заменяется запасной
85
+ фразой `labels.linkOnlyReply` (`russianReplyCheckLabels`, `englishReplyCheckLabels`, `ReplyCheckLabels`).
86
+ - **react**: договор скина версии 24 — необязательное `undelivered` у ответа агента и надпись `answerUndelivered`
87
+ («Не доставлено»). Скин версии 23 работает без правок.
88
+
89
+ ### Как обновить приложение
90
+
91
+ 1. Поднимите все пакеты `@ykurilov/agent-chat-*` до этой версии вместе.
92
+ 2. Найдите в коде приложения исчерпывающие `switch` и словари по этим типам и добавьте ветки:
93
+ - по `FlowFieldType` (поля карточки сценария) — `"boolean"`, «да/нет», значения `true`/`false`;
94
+ - по `UsageSource` (отчёты расходов) — `"first_message"`, «агент написал первым»;
95
+ - по `SteerResult.status` — `"held"`, «сообщение записано, хода нет».
96
+ 3. Своё хранилище сценариев (свой `FlowStore`) — добавьте `renewLease({ chatId, owner, now, leaseMs }): Promise<boolean>`
97
+ (продлить аренду до `now + leaseMs`; `false` — чат уже не ваш) и третий аргумент `setDue(chatId, due, { unlessDelivery })`
98
+ (при `unlessDelivery` не писать срок, пока чат ждёт повтора доставки). Готовые хранилища SDK это уже умеют.
99
+ 4. Сценарии сохраняются версией 2, а 0.48 её не откроет. Перед откатом на 0.48 держите копии сценариев версии 1.
100
+ 5. Новое по желанию: каналы SDK и сценарий — `createMessengers({ onAnswerDelivery: (report) => flow.reportDelivery(report) })`;
101
+ проверка ответа — `pnpm add @ykurilov/agent-chat-reply-check` и `replyChecks` в `createChatServer`.
102
+ 6. Проверка: `pnpm typecheck` проходит, чат отвечает, сценарий версии 1 открывается в редакторе.
103
+
104
+ ### Patch Changes
105
+
106
+ - Updated dependencies [084cef7]
107
+ - @ykurilov/agent-chat-core@0.49.0
108
+
109
+ ## 0.48.0
110
+
111
+ ### Patch Changes
112
+
113
+ - fa10d5a: Сценарии поведения агента: разговор идёт по шагам с условиями выхода, судьёй, таймерами «написать первым» и карточкой
114
+ клиента, плюс редактор сценария с тестовым чатом. Подробно — `docs/flows.md`.
115
+
116
+ - **flows** (новый пакет): `createAgentFlow`, формат и проверка сценария, судья decision-1 с запасной моделью, таймеры,
117
+ карточка клиента, перевод чатов на новую версию, хранилища SQLite и PostgreSQL, `FlowEditor`, `FlowTestChat`, `ClientCardPanel`.
118
+ - **core**: новые точки для расширений — `agent.prepareTurn` (контекст хода с `resumed`, `audience`, `staff?`),
119
+ служебное сообщение `app`, готовый ответ `deliverNotices({ reply })`, `askModel` и `recordUsage`, источник расхода
120
+ `judge`, `ChatService.assertWithinLimit(ownerId, chatId)`; `chatInstructions(chat, turn?)` — второй аргумент с
121
+ `audience` (при сжатии истории его нет).
122
+ - **server**: `chatInstructions` в настройках принимает тот же второй аргумент `turn`.
123
+ - **messengers**: ответ сервера без модели (точный текст по таймеру сценария) теперь доходит до мессенджера.
124
+ - **skin-tandem**: новые входы `/flows` (`tandemFlowEditorSkin`) и `/client-card` (`tandemClientCardSkin`).
125
+
126
+ ### Как обновить приложение
127
+
128
+ Сценарии не нужны — обновление ничего не требует. Нужны — шаги в разделе «Как обновить приложение» в `CHANGES.md`:
129
+ `pnpm add @ykurilov/agent-chat-flows`, `createAgentFlow` + `flow.withChat(…)`, `flow.attach(service)`, `flow.start()`,
130
+ `OPENROUTER_API_KEY` на сервере. Тестовому чату нужен `readRun(chatId)` в `FlowTestChatConnection`; его адреса
131
+ закройте правами редактора. `firstMessages: false` теперь значит «без тихих часов и потолка», запрет —
132
+ `canWriteNow: () => false`. Проверка: `pnpm typecheck`, новый чат отвечает по первому состоянию сценария.
133
+ - Updated dependencies [fa10d5a]
134
+ - @ykurilov/agent-chat-core@0.48.0
135
+
136
+ ## 0.47.0
137
+
138
+ ### Patch Changes
139
+
140
+ - Updated dependencies [5a59c33]
141
+ - @ykurilov/agent-chat-core@0.47.0
142
+
143
+ ## 0.46.2
144
+
145
+ ### Patch Changes
146
+
147
+ - Updated dependencies [3d0d2f2]
148
+ - @ykurilov/agent-chat-core@0.46.2
149
+
150
+ ## 0.46.1
151
+
152
+ ### Patch Changes
153
+
154
+ - @ykurilov/agent-chat-core@0.46.1
155
+
156
+ ## 0.46.0
157
+
158
+ ### Patch Changes
159
+
160
+ - Updated dependencies [56d7528]
161
+ - Updated dependencies [a1933fd]
162
+ - Updated dependencies [52cc67d]
163
+ - @ykurilov/agent-chat-core@0.46.0
164
+
165
+ ## 0.45.0
166
+
167
+ ### Patch Changes
168
+
169
+ - @ykurilov/agent-chat-core@0.45.0
170
+
171
+ ## 0.44.0
172
+
173
+ ### Patch Changes
174
+
175
+ - Updated dependencies [6a2b5d9]
176
+ - @ykurilov/agent-chat-core@0.44.0
177
+
178
+ ## 0.43.0
179
+
180
+ ### Patch Changes
181
+
182
+ - Updated dependencies [763f4c5]
183
+ - @ykurilov/agent-chat-core@0.43.0
184
+
185
+ ## 0.42.0
186
+
187
+ ### Minor Changes
188
+
189
+ - a385276: Публичный чат больше не выдаёт посетителю служебное: модель, провайдера, расход, текст сводки сжатия и закрытые
190
+ ключи состояния. Ничего не ломает: по умолчанию всё как раньше.
191
+
192
+ - Новое право `modelDetails` в `chatCapabilities` (по умолчанию `true`). `false` убирает из истории и потока ответа
193
+ модель, провайдера и уровень рассуждений чата, `ownerId`, `metadata.modelId`/`providerId`/`usage` у сообщений,
194
+ `providerMetadata`/`callProviderMetadata`/`resultProviderMetadata` у частей и кусков, провайдера у отметки
195
+ `data-step-reset`, текст сводок сжатия (`summary: ""`, `summaryTokens: 0` в истории и `GET …/compactions`) и
196
+ выключает выбор модели (`modelChoice`). То же при переподключении и смене ветки. Хранилище, модель, хуки и лимиты
197
+ получают всё.
198
+ - Опция сервера и службы `modelMenu: ({ userId }) => boolean` закрывает `GET …/models` (403 `action_forbidden`,
199
+ `action: "modelDetails"`): меню чата не знает, право его не закрывает.
200
+ - Тандем рисует разделитель сжатия без текста сводки строкой без кнопки.
201
+ - Опция `privateStateKeys: ["booking"]` — закрытые верхние ключи состояния чата: не уходят ни в историю, ни в поток,
202
+ ни в `state-changed` наблюдения (`useChatState` их не видит). `getChatState`, `onStateChanged`, события и модель
203
+ видят их как обычно. Имя без точки и не на `$`, иначе `TypeError` при создании. Закрытое значение не возвращайте в
204
+ выходе инструмента (его прячет лишь `toolDetails: false`) и в тексте ответа (не прячется никак).
205
+ - Экран запрашивает меню моделей после истории и только если оно нужно: разрешён выбор модели (или вложениям старого
206
+ сервера). История всегда несёт `modelAcceptsImages` — видит ли модель чата картинки.
207
+
208
+ ### Как обновить приложение
209
+
210
+ Публичному чату (посетитель без входа) в `server/chat.ts` или где создаётся обработчик. Посетитель — человек со своим
211
+ `userId` от `resolveUser` (например, `visitor:<id сессии из cookie>`); `isVisitor = (userId) => userId.startsWith("visitor:")`.
212
+ `modelDetails: false` прячет и модель, и текст сводок сжатия; `toolDetails: false` сводку не трогает.
213
+
214
+ ```ts
215
+ createChatHandler({
216
+ // …
217
+ chatCapabilities: ({ userId }) => (isVisitor(userId) ? { modelDetails: false, toolDetails: false, reasoning: false /* , … */ } : {}),
218
+ modelMenu: ({ userId }) => !isVisitor(userId),
219
+ privateStateKeys: ["booking"], // верхние ключи состояния со служебными id
220
+ });
221
+ ```
222
+
223
+ Свой скин: `CompactionDivider` может получить `summary: ""` (при `modelDetails: false`) — раскрывать нечего, покажите
224
+ только строку.
225
+
226
+ ### Patch Changes
227
+
228
+ - Updated dependencies [a385276]
229
+ - @ykurilov/agent-chat-core@0.42.0
230
+
231
+ ## 0.41.0
232
+
233
+ ### Patch Changes
234
+
235
+ - Updated dependencies [9472fcb]
236
+ - @ykurilov/agent-chat-core@0.41.0
237
+
238
+ ## 0.40.0
239
+
240
+ ### Minor Changes
241
+
242
+ - 0e7aab3: Наблюдения «Умных форм», без ломающих изменений:
243
+
244
+ - `useChatState(chatId)` — состояние чата вне `AgentChat` (панель рядом с чатом): те же значения и живые обновления,
245
+ без своих запросов.
246
+ - `onModelChosen` — человек выбрал модель в меню: колбэк `AgentChat` и хук службы (`createChatService`, `createChatServer`).
247
+ - `{ by: "app" }` у `service.deleteChat` и `service.setModel` — сервер приложения действует без прав экрана; адреса HTTP
248
+ проверяют права, как раньше.
249
+ - «Переделать» у приветствия скрыто, а сервер такой запрос отклоняет (`InvalidMessageError`).
250
+ - `recoverInterruptedAnswers({ soleServer: true })` — у единственной копии сервера оборванные ответы прерываются сразу,
251
+ а не через 15–20 с. Только при старте процесса; не с несколькими копиями на одной базе и не в dev-режиме Next.js.
252
+
253
+ Как обновить: поднять версию, правки не обязательны. Обходные пути можно заменить — см. CHANGES.md «0.40.0».
254
+
255
+ ### Patch Changes
256
+
257
+ - Updated dependencies [0e7aab3]
258
+ - @ykurilov/agent-chat-core@0.40.0
259
+
260
+ ## 0.39.1
261
+
262
+ ### Patch Changes
263
+
264
+ - @ykurilov/agent-chat-core@0.39.1
265
+
266
+ ## 0.39.0
267
+
268
+ ### Minor Changes
269
+
270
+ - 3b90429: Виджеты: `onStateChanged` с причиной `widgetAnswer` после записей `checkAnswer`; `ctx.emitAppEvent` в `checkAnswer`
271
+ (событие вместе с ответом виджета, на странице — живым один раз); `chatId` и `toolCallId` в `CustomWidgetProps`;
272
+ `agent.widgets.enabledFor` — набор виджетов по чату, выключенных модель не видит, их прошлые вызовы идут модели текстом.
273
+ Всё необязательное, ломающих изменений нет. Как обновить приложение — в CHANGES.md, раздел 0.39.0.
274
+
275
+ ### Patch Changes
276
+
277
+ - Updated dependencies [3b90429]
278
+ - @ykurilov/agent-chat-core@0.39.0
279
+
280
+ ## 0.38.0
281
+
282
+ ### Patch Changes
283
+
284
+ - Updated dependencies [1cae504]
285
+ - Updated dependencies [4065a4a]
286
+ - @ykurilov/agent-chat-core@0.38.0
287
+
288
+ ## 0.37.0
289
+
290
+ ### Patch Changes
291
+
292
+ - Updated dependencies [7f49d12]
293
+ - Updated dependencies [ea78982]
294
+ - @ykurilov/agent-chat-core@0.37.0
295
+
296
+ ## 0.36.0
297
+
298
+ ### Minor Changes
299
+
300
+ - 3487fb4: Публичному посетителю можно не показывать кухню агента. Новые права в `chatCapabilities`: `reasoning` (мысли модели) и
301
+ `toolDetails` (служебные инструменты). По умолчанию оба `true` — всё как раньше.
302
+
303
+ - При запрете мысли и вызовы служебных инструментов (вход, выход, запрос подтверждения) не уходят в браузер: ни в историю
304
+ (`GET`, `getHistory`, `peekHistory`, `selectBranch`), ни в поток ответа, ни в повтор кусков после переподключения
305
+ (`GET …/stream`). Из метаданных ответа пропадает `reasoningDurationsMs`, у текста и вызовов — детали рассуждений
306
+ OpenRouter (`providerMetadata.openrouter.reasoning_details`).
307
+ - Модель, хранилище, `afterTurn`, сжатие и поиск по истории получают ответ целиком.
308
+ - Виджеты (`agent.widgets`) и браузерные инструменты видны всегда. Свой инструмент с карточкой на экране пометьте в его
309
+ описании — `metadata: { showOnScreen: true }` (поле AI SDK, модели не уходит). Метка записывается в каждый вызов
310
+ (`toolMetadata`), поэтому работает и для набора инструментов функцией от модели. Вызовы, записанные до пометки, метки
311
+ не имеют и при запрете скрыты.
312
+
313
+ ```ts
314
+ const showCard = tool({ description: "Карточка тарифа", inputSchema, metadata: { showOnScreen: true }, execute });
315
+ createChatServer({
316
+ agent: { instructions, tools: { show_card: showCard, lookup } },
317
+ chatCapabilities: ({ userId }) => (isVisitor(userId) ? { reasoning: false, toolDetails: false } : {}),
318
+ });
319
+ ```
320
+
321
+ - Права считаются на того, кто открыл поток, в момент открытия.
322
+ - Вызов, который спросил подтверждения (`needsApproval`), виден сам, без пометки: иначе человек не увидит вопроса и ход
323
+ зависнет. Вызов того же инструмента без вопроса скрыт.
324
+ - Свои тесты, которые сравнивают `capabilities` истории целиком, получат два новых поля `reasoning` и `toolDetails`.
325
+
326
+ Ломает типы: тип ChatCapabilities получил новые обязательные поля, литералы надо дополнить.
327
+ - 3f39081: Подключение к Express и голому `node:http` без своей обёртки: `toNodeListener(handler)` из нового подпути
328
+ `@ykurilov/agent-chat-server/node`. Рядом с `mountChat` (`/hono`) и `createNextRouteHandlers` (`/next`).
329
+
330
+ ```ts
331
+ import { toNodeListener } from "@ykurilov/agent-chat-server/node";
332
+
333
+ app.use(toNodeListener(chatHandler)); // Express
334
+ http.createServer(toNodeListener(chatHandler)).listen(3000); // node:http
335
+ ```
336
+
337
+ - Ответ идёт потоком, по кускам, без буфера; несколько `Set-Cookie` не склеиваются.
338
+ - Разрыв соединения до конца ответа отменяет запрос (`request.signal`) и поток ответа.
339
+ - Тело запроса идёт потоком. Если его уже прочитал разборщик (`express.json()`), уходит `req.body` как есть.
340
+ - Адрес берётся из `req.originalUrl` (монтирование `app.use("/api/chat", …)` его не портит). Адрес вне `basePath` —
341
+ `next()`, без `next` — 404.
342
+ - Адрес строится на базе `http://localhost`, без заголовка Host: кривой Host или адрес — ответ 400, процесс не падает.
343
+ - Ошибка обработчика после разрыва соединения в `next` не уходит.
344
+
345
+ Если приложение держит свою обёртку `IncomingMessage → Request`, её можно заменить этим вызовом.
346
+ - 5f13c74: Поправку к идущему ответу можно запретить правом `steer` в `chatCapabilities`. Раньше `POST …/steer` принимался, даже
347
+ если экран прятал поправку (`steering={false}`). Теперь при `steer: false` сервер отвечает 403
348
+ `{ error: { kind: "action_forbidden", action: "steer", … } }` до любой записи, `service.steer` бросает
349
+ `ActionForbiddenError`, а `<AgentChat>` не предлагает поправку и ждёт конца ответа. По умолчанию право `true`.
350
+
351
+ ```ts
352
+ chatCapabilities: ({ userId }) => (isVisitor(userId) ? { steer: false, edit: false, regenerate: false, branches: false } : {}),
353
+ ```
354
+
355
+ Свои тесты, которые сравнивают `capabilities` истории целиком, получат новое поле `steer`.
356
+
357
+ Ломает типы: тип ChatCapabilities получил новые обязательные поля, литералы надо дополнить.
358
+
359
+ ### Patch Changes
360
+
361
+ - Updated dependencies [3487fb4]
362
+ - Updated dependencies [8c43b86]
363
+ - Updated dependencies [6f42418]
364
+ - Updated dependencies [5f13c74]
365
+ - @ykurilov/agent-chat-core@0.36.0
366
+
367
+ ## 0.35.0
368
+
369
+ ### Patch Changes
370
+
371
+ - 6ceee78: Две правки клиента.
372
+
373
+ - Чат больше не просит `GET {chat}/tasks` у сервера без фоновых задач (раньше — 404 и красная строка в консоли при каждом открытии). История теперь несёт `backgroundTasks: true | false` (сервер знает, подключён ли `background`), клиент при `false` задач не запрашивает. Сервер до этой правки поля не присылает: клиент спрашивает адрес задач, как раньше, поэтому приложения со старым сервером и фоном продолжают работать.
374
+ - Скрытая строка для экранного диктора получает текст ответа без разметки Markdown: заголовки, жирный, курсив, зачёркнутое, код, списки и цитаты без значков, у ссылки остаётся её текст. Звёздочки больше не зачитываются вслух. Обычный текст не трогается: выделение снимается только целыми словами, поэтому «5\*6\*7», «src/\*.ts» и «\_\_init\_\_» остаются; «> 100 000 ₽» — не цитата; решётка в конце снимается только у заголовка.
375
+ - Updated dependencies [2382026]
376
+ - @ykurilov/agent-chat-core@0.35.0
377
+
378
+ ## 0.34.0
379
+
380
+ ### Minor Changes
381
+
382
+ - d039ce4: Поправка на лету: человек пишет агенту, пока тот отвечает, и не ждёт конца ответа и не жмёт «Стоп».
383
+
384
+ Что это для человека: поле ввода во время ответа свободно, «Стоп» рядом с «Отправить». Поправка сразу видна в ленте с пометкой
385
+ «Агент учтёт на следующем шаге». Агент берёт её после очередных вызовов инструментов и продолжает с её учётом; сделанное до
386
+ поправки остаётся. Ответ кончился раньше — поправка запускает новый ход. Несколько поправок — все, по порядку. «Стоп» поправку
387
+ не теряет. Поправка хранится в базе и видна после обновления страницы.
388
+
389
+ Новое:
390
+
391
+ - ядро: `service.steer(userId, chatId, message, { page? })` → `{ status: "queued" | "started", messageId }`; в истории `pendingMessages`
392
+ (ждущие поправки); `isCorrection`, `STEER_PART_TYPE`, тип `SteerResult`; повтор с тем же `message.id` — тот же итог без второй копии;
393
+ - сервер: `POST …/:id/steer` `{ message, place?, tabId? }` → `202`;
394
+ - react: проп `steering` у `AgentChat` (включено, если скин рисует поправки; `false` — выключить), опция `steering` у
395
+ `useAgentChat` (по умолчанию выключено), у него же `canSteer`, `corrections`; надпись `steeringPending`
396
+ (русская по умолчанию, английская в `englishWorkLabels`);
397
+ - договор скина 21: `ChatSkin.steering?`, `Composer.canSteer?`, `UserMessage.pending?` ([docs/skin-v21.md](https://github.com/ykurilov/agent-chat-sdk/blob/main/docs/skin-v21.md));
398
+ - Тандем: во время ответа «Остановить» рядом с «Отправить» в рамке поля, пометка под ждущей поправкой. На узком экране
399
+ кнопка голосового режима на время ответа прячется, чтобы ряд не вылез за край.
400
+
401
+ ### Как обновить приложение
402
+
403
+ Обязательных шагов нет: со скином Тандема поправки включаются сами, свой скин и своя лента работают как раньше.
404
+
405
+ 1. Проверьте, что `@ykurilov/agent-chat-server` обновлён вместе с `@ykurilov/agent-chat-react`: старый сервер не знает `POST …/steer`,
406
+ и поправка вернётся в поле с ошибкой.
407
+ 2. Свой словарь надписей (полный объект, не поверх `russianLabels`) — добавьте `steeringPending`. Без неё будет русская надпись по умолчанию.
408
+ 3. Свой скин: без `steering: true` поле во время ответа занято, как раньше, — поправок у людей не будет. Чтобы были, поддержите
409
+ `Composer.canSteer` и `UserMessage.pending` и поставьте в скин `steering: true` по [docs/skin-v21.md](https://github.com/ykurilov/agent-chat-sdk/blob/main/docs/skin-v21.md).
410
+ 4. Своя лента на `useAgentChat`: включите `useAgentChat({ steering: true })`, рисуйте `corrections` после `messages`, пометкой — `steeringPending`.
411
+ 5. Несколько копий сервера: поправка принимается копией, на которой идёт ответ (как «Стоп»), иначе `409 busy`. Держите запросы
412
+ одного чата на одной копии.
413
+ 6. Не нужно со скином Тандема — `<AgentChat steering={false} />`.
414
+ 7. Проверка: `pnpm typecheck`; в чате во время долгого ответа отправьте поправку — она видна с пометкой, затем агент отвечает с её учётом.
415
+
416
+ ### Patch Changes
417
+
418
+ - Updated dependencies [d039ce4]
419
+ - @ykurilov/agent-chat-core@0.34.0
420
+
421
+ ## 0.33.0
422
+
423
+ ### Patch Changes
424
+
425
+ - Updated dependencies [4a72339]
426
+ - @ykurilov/agent-chat-core@0.33.0
427
+
428
+ ## 0.32.0
429
+
430
+ ### Patch Changes
431
+
432
+ - ac9b7d3: «Стоп» больше не теряется, пока ход ещё запускается. Раньше остановка, нажатая во время проверок перед ходом
433
+ (`beforeTurn`, лимит трат), отвечала «нечего останавливать», а ход потом шёл до конца. Теперь `stop` в это время
434
+ возвращает `true`, и ход останавливается сразу после старта; адрес `/stop` отвечает 202.
435
+
436
+ Обновление ничего не требует.
437
+ - 53c41a8: Поток событий чата (`watch`) снова видит конец фоновых задач, даже если первая сверка с базой упала. Раньше после
438
+ такого сбоя сверка больше не запускалась до переподключения вкладки.
439
+
440
+ Обновление ничего не требует.
441
+ - Updated dependencies [71c32b7]
442
+ - Updated dependencies [cae13bf]
443
+ - Updated dependencies [a94bda9]
444
+ - Updated dependencies [4e44492]
445
+ - Updated dependencies [ac9b7d3]
446
+ - Updated dependencies [27f4b1e]
447
+ - @ykurilov/agent-chat-core@0.32.0
448
+
449
+ ## 0.31.2
450
+
451
+ ### Patch Changes
452
+
453
+ - @ykurilov/agent-chat-core@0.31.2
454
+
455
+ ## 0.31.1
456
+
457
+ ### Patch Changes
458
+
459
+ - @ykurilov/agent-chat-core@0.31.1
460
+
461
+ ## 0.31.0
462
+
463
+ ### Patch Changes
464
+
465
+ - @ykurilov/agent-chat-core@0.31.0
466
+
467
+ ## 0.30.0
468
+
469
+ ### Patch Changes
470
+
471
+ - @ykurilov/agent-chat-core@0.30.0
472
+
473
+ ## 0.29.0
474
+
475
+ ### Patch Changes
476
+
477
+ - @ykurilov/agent-chat-core@0.29.0
478
+
479
+ ## 0.28.0
480
+
481
+ ### Minor Changes
482
+
483
+ - d63ccaa: Ломает приложения: закрытие чата теперь запись на ветке, а не флаг навсегда. Человек исправил сообщение до точки
484
+ закрытия, нажал «Переделать» или перешёл на другую ветку — на новой ветке чат открыт. Вернулся стрелкой назад — снова
485
+ закрыт. Отправка, «Повторить», подтверждения и виджеты в закрытом чате по-прежнему запрещены (409 `chat_closed`).
486
+
487
+ Плюс приложение узнаёт о смене состояния чата (`model.state`): на сервере — хук, на экране — проп и хук React.
488
+
489
+ - **Сервер:** `onStateChanged({ ownerId, chatId, reason, state, previousState, atMessageId })` у `createChatServer` и
490
+ `createChatService`. Зовётся после хода и смены ветки (`reason`: `turn`, `editMessage`, `regenerate`, `retry`,
491
+ `selectBranch`, `editAnswer`), только если состояние сменилось. Ход его не ждёт. Вызовы одного чата — по очереди.
492
+ Сбой — повторы (`onStateChangedAttempts`, 3) в сроке (`onStateChangedTimeoutMs`, 30 000), потом строка в `logError`.
493
+ Доставка только пока сервер жив: упал между ответом и хуком — хук не позовётся, сверяйте по `getChatState`.
494
+ - **События:** `state-changed` `{ chatId, reason, state, atMessageId }` (вкладка получает через `watch`) и
495
+ `state-changed-failed`. `chat-closed` и `chat-reopened` теперь приходят и при смене ветки.
496
+ - **Экран:** `<AgentChat onStateChange={(state) => …} />` — после загрузки и при каждой смене, в том числе по ходу
497
+ ответа и из другой вкладки (событие `state-changed`). Внутри ленты — `useChatState()`, у `useAgentChat` — `chatState`. Тип `ChatStateValues`.
498
+ - **Ответ сохраняется в базу сразу после итога каждого инструмента**, а не раз в полсекунды: запись состояния
499
+ переживает падение сервера. Промежуточные результаты (`preliminary`) — по-прежнему раз в полсекунды.
500
+ - **Служебные ключи** состояния (`$…`) больше не уходят в браузер: ни в истории, ни в потоке ответа.
501
+ - **`getChatState` дешёвый:** читает только связи сообщений и записи состояния. SQLite и Postgres получили метод
502
+ `listMessagePartsOfTypes(chatId, types)`. Новых таблиц и миграций нет.
503
+ - **Колонка `closed`** в записи чата — копия закрытия текущей ветки для `listChats`. `chatCapabilities` (`chat.closed`)
504
+ и история берут закрытие с ветки. Не записалась копия — её дописывает следующее открытие истории.
505
+ - **Чаты, закрытые в 0.27,** остаются закрытыми. Перенос в запись ленивый, без миграции: запись появится при первой
506
+ правке, «Переделать», выборе ветки или правке ответа в этом чате.
507
+ - Ядро экспортирует `CLOSED_STATE_KEY`, `isStateMessage`, `publicAppStateParts`, `DEFAULT_STATE_CHANGED_ATTEMPTS`,
508
+ `DEFAULT_STATE_CHANGED_TIMEOUT_MS`, типы `StateChangedHandler`, `StateChangedInput`, `StateChangeReason`,
509
+ `MessagePartsLink`.
510
+
511
+ ### Как обновить приложение
512
+
513
+ 1. **Закрытие должно быть навсегда** (нельзя уйти правкой или веткой): в настройках сервера (`createChatServer`)
514
+ добавьте права по закрытию.
515
+
516
+ Было: ничего.
517
+
518
+ Стало:
519
+
520
+ ```ts
521
+ chatCapabilities: ({ chat }) =>
522
+ chat.closed ? { edit: false, regenerate: false, branches: false, answerEditing: false } : {},
523
+ ```
524
+
525
+ Если `chatCapabilities` уже есть — допишите эту ветку в него. Если правка до закрытия вам подходит — шаг пропустите.
526
+ 2. **`switch` по событиям чата** (`ChatEvent`, `service.events`) с проверкой полноты (`never`): добавьте ветки
527
+ `state-changed` и `state-changed-failed`.
528
+ 3. **Своё хранилище** (не SQLite и не Postgres): ничего не обязательно. Для дешёвого чтения состояния добавьте
529
+ `listMessagePartsOfTypes(chatId, types)` → `[{ id, parentId, createdAt, parts }]` в порядке `listMessages`, только
530
+ части нужных типов. Общие тесты хранилища проверят его, флаг `partsOfTypes: "required"` делает его обязательным.
531
+ `setChatClosed` теперь пишет только копию для списка: без него закрытие работает, но список его не видит.
532
+ 4. **Своя реализация `ChatService`** (заглушки в тестах): полей не прибавилось. Если вы проверяли, что правка или
533
+ «Переделать» в закрытом чате дают `ChatClosedError`, — теперь они идут.
534
+ 5. **Таблица-копия состояния** (по желанию): `onStateChanged` с полной заменой записи —
535
+ `await db.upsertForm({ chatId, fields: state.fields ?? {} })`.
536
+ 6. Проверка: `pnpm typecheck`; закройте чат инструментом, исправьте вопрос до закрытия — без шага 1 чат открыт, с
537
+ шагом 1 правка недоступна.
538
+ - 189cba9: Проверка ответа виджета на сервере. Раньше ответ проверялся только схемой. Теперь приложение может проверить его с
539
+ доступом к своей базе («это время уже заняли»), отказать человеку своими словами, подменить ответ и записать состояние чата.
540
+
541
+ ```ts
542
+ agent: {
543
+ widgets: {
544
+ custom: [
545
+ {
546
+ ...pickSlot, // виджет из общего файла; проверка — только на сервере
547
+ checkAnswer: async (answer, ctx) => {
548
+ const hold = await bookings.tryHold({ key: ctx.toolCallId, chatId: ctx.chatId, start: answer.slot });
549
+ if (!hold) return { refuse: "Это время уже заняли. Выберите другое." }; // виджет остаётся открытым
550
+ ctx.state.set("booking.holdId", hold.id); // ляжет в ответ вместе с результатом виджета
551
+ return { answer: { ...answer, holdId: hold.id } }; // подмена ответа; можно вернуть и undefined — принять как есть
552
+ },
553
+ },
554
+ ],
555
+ },
556
+ }
557
+ ```
558
+
559
+ - Проверка зовётся после схемы, пока чат удержан за этим ответом. `ctx`: `ownerId`, `chatId`, `messageId`, `toolName`,
560
+ `toolCallId`, `input`, `subject?`, `chatData?`, `state` (запись, как `model.state`), `signal` (`AbortSignal`). Исходы: `undefined` — принять,
561
+ `{ answer }` — принять с заменой (её видят модель и экран; до 16 КБ, схемой не перепроверяется), `{ refuse }` — отказать
562
+ текстом до 300 знаков.
563
+ - Общая проверка для всех виджетов без своей, в том числе базовых (`ask_choice` и др.):
564
+ `agent.widgets.checkAnswer(call, answer, ctx)`, где `call` — `{ toolName, toolCallId, input }`.
565
+ - Отказ: служба бросает `WidgetAnswerRefusedError`, сервер отвечает `409 { error: { kind: "widget_answer_refused", code,
566
+ message } }` (новый вид в `HandlerErrorKind`), экран показывает текст под виджетом и оставляет виджет открытым; поле ввода и
567
+ `onTurnRefused` не трогает. Ничего не записано, повтор ответа возможен.
568
+ - Записи `ctx.state` принятого ответа ложатся частями `data-app-state` вместе с результатом виджета; отказ и исключение их
569
+ отбрасывают. `getChatState` и следующий ход видят их.
570
+ - Исключение проверки — ответ 500, ничего не записано, виджет открыт, в `logError` строка
571
+ `[виджет] ошибка проверки чат=… вызов=…` с именем ошибки, без её текста.
572
+ - Срок проверки (`agent.widgets.checkAnswerTimeoutMs`, по умолчанию 5 000 мс) — не сбой: служба бросает
573
+ `WidgetAnswerCheckTimeoutError`, сервер отвечает `504 { error: { kind: "widget_check_timeout", code, message } }` (новый вид
574
+ в `HandlerErrorKind`), ничего не записано, виджет открыт, в журнале строка `[виджет] отказ answer_check_timeout …`, а не
575
+ ошибка. Страница показывает под виджетом новую подпись `widgets.answerCheckTimeout` («Не удалось проверить ответ,
576
+ попробуйте ещё раз»; её можно переопределить в `labels`), у `WidgetFailureKind` новый вид `check_timeout`. Зависшая
577
+ проверка не держит чат занятым. Срок проверки держите заметно меньше `requestTimeoutMs` страницы (10 000 мс).
578
+ - `ctx.signal` (`AbortSignal`) срабатывает по сроку проверки и по «Стопу» в чате. После отмены служба ничего не пишет,
579
+ виджет остаётся открытым, но остановить работу приложения она не может: слушайте сигнал сами
580
+ (`fetch(url, { signal: ctx.signal })`). «Стоп» во время проверки — не сбой: служба бросает новую
581
+ `WidgetAnswerCheckAbortedError`, сервер отвечает `409 { error: { kind: "widget_answer_cancelled", code, message } }`
582
+ (новый вид в `HandlerErrorKind`), в журнале строка `[виджет] отказ answer_cancelled …`, страница тихо оставляет виджет
583
+ открытым, без надписи под ним.
584
+ - Чат закрыли (`closeChat`) во время проверки: ответ не пишется, служба бросает `ChatClosedError`, ждущий виджет закрывается
585
+ отказом, как при обычном закрытии.
586
+ - Для React: у `WidgetFailureKind` новый вид `answer_refused` (текст отказа в `text`). Скины, которые перебирают виды отказа
587
+ полным `switch`, добавляют ветку; готовый скин и `AgentChat` ничего не требуют.
588
+ - Без `checkAnswer` запрос к модели, хранилище и ответы сервера прежние.
589
+
590
+ Обновление ничего не требует. Если приложение разбирает ответы сервера полным `switch` по `HandlerErrorKind`, добавьте
591
+ `widget_answer_refused`, `widget_answer_cancelled` и `widget_check_timeout`. Подробности и пример — `docs/widgets.md`, раздел «Проверка ответа на сервере».
592
+
593
+ ### Patch Changes
594
+
595
+ - Updated dependencies [11c3c97]
596
+ - Updated dependencies [d63ccaa]
597
+ - Updated dependencies [6a2f903]
598
+ - Updated dependencies [189cba9]
599
+ - @ykurilov/agent-chat-core@0.28.0
600
+
601
+ ## 0.27.0
602
+
603
+ ### Minor Changes
604
+
605
+ - d8341b2: Свои данные чата (`chat.data`) и защита таблиц SQLite. Приложения без новых опций работают как раньше: `subjectData` остался и равен `data`.
606
+
607
+ - **`chat.data`** — JSON-объект до 16 КБ у любого чата (раньше такие данные были только у чатов объектов). Лежит в том же столбце `subject_data`: миграции нет, старые данные чатов объектов уже читаются как `data`. В браузер и в запрос к модели данные не уходят: кэш модели не меняется.
608
+ - **Новый чат:** `service.createChat(userId, { data })`. Из браузера: `createChatServer({ newChatData: ({ user, request }) => data })` — данные ставит сервер, браузер их не присылает. Вернули не объект — 500. Хранилище без `updateChatData` с `newChatData` — ошибка при создании обработчика; `createChat({ data })` на таком хранилище — та же понятная ошибка, данные молча не теряются.
609
+ - **Правка:** `service.updateChatData(userId, chatId, patch)` — сливает верхние ключи (ключ со значением `undefined` удаляется, `null` остаётся значением). Итог больше 16 КБ — `TypeError`, данные не меняются. Возвращает новые данные. Правка атомарная: две одновременные правки разных ключей не затирают друг друга. Новое значение видно со следующего хода. Верхний ключ `__proto__` в данных, правке и фильтре не принимается (`TypeError`): иначе значение тихо пропало бы при слиянии.
610
+ - **Список:** `service.listChats(userId, { data: { formId: "f1", mode: "test" } })` — чаты, у которых все указанные верхние ключи равны значениям. Значения только строка, число, булево или `null` (иначе `TypeError`); `1` и `"1"` — разные значения. Можно вместе с `subjects`. SQLite ищет через `json_each`, PostgreSQL — через `jsonb @>`.
611
+ - **Кто видит данные:** `beforeTurn` и `chatInstructions` — `chat.data`; инструменты — `model.chat.data` (в том числе при сжатии и у фоновых помощников); `afterTurn` — `turn.chatData` (данные на момент итога: правка посреди хода видна, и итог после перезапуска даёт то же). `chatCapabilities`, проверка виджета и `stepContext` получают `Chat` и тоже видят `data`.
612
+ - **Событие `chat-data-changed { ownerId, chatId }`** (без данных) для `onChatEvent`. Вкладке браузера оно не пересылается.
613
+ - **Своё хранилище:** добавьте необязательный метод `updateChatData(chatId, patch, { maxBytes })` (одна атомарная операция, возвращает чат или `null`; больше предела — `ChatDataTooLargeError`), `listChats(ownerId, { subjects?, data? })` и запись/чтение `data` (у старого кода — `subjectData`). Без `updateChatData` служба отвечает понятной ошибкой на `updateChatData` и `listChats({ data })`. Проверка — `describeChatStorageContract(…, { chatData: "required" })`.
614
+ - **Песочница:** в копии чата человека инструменты и `afterTurn` видят данные исходного чата, а не служебную обёртку копии; это верно и для обычного чата без ключа объекта (`subject` у него по-прежнему нет).
615
+ - **SQLite: защита от чужих таблиц.** Таблицы SDK не переименованы: SDK должен жить в своём файле. При открытии, до миграций, SDK смотрит, нет ли в файле чужих `chats` или `messages` (журнал миграций SDK не знает их). Тогда `createSqliteChatStorage` бросает `ForeignChatTablesError` с текстом «дайте SDK отдельный файл», файл не тронут. Если у приложения свои `chats`/`messages` в том же файле — укажите SDK другой `filePath`. Раздел «Где лежат данные SDK» — в README хранилищ.
616
+ - 5e4ed28: **Что может сломаться:**
617
+
618
+ - исчерпывающий `switch` по виду ошибки сервера (`HandlerErrorKind`) и виду отказа на экране (`AppRefusalKind`): новые значения `action_forbidden`, `chat_closed`, `chat_creation_refused`;
619
+ - исчерпывающий `switch` по причине `turnNotStarted` (`TurnNotStartedReason`): новое значение `chat_closed`;
620
+ - свой словарь надписей `ChatLabels`, собранный целиком: новое обязательное поле `chatListClosed`.
621
+
622
+ Права человека в чате и закрытый чат. Приложение функцией `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.
623
+
624
+ - Права: опечатка в названии права (`edits` вместо `edit`) — ошибка приложения (`TypeError`), а не молчаливый доступ. За один вызов службы права спрашиваются не больше раза.
625
+ - Закрытие побеждает паузу: если чат закрыт, пока ответ ждёт подтверждения или виджета, ждущие запросы закрываются отказом, как при «Стопе», и ответ считается законченным.
626
+ - Закрытие из инструмента записывается сразу при вызове: после падения сервера чат уже закрыт. Чат, закрытый во время проверки перед ходом, хода не начинает: 409 `chat_closed`, резерв приложения закрывается `turnNotStarted` с причиной `chat_closed`.
627
+ - Новый чат: `beforeCreateChat({ userId, origin? })` вернул `{ code, message }` — `POST {base}` отвечает `403 { error: { kind: "chat_creation_refused", code, message } }`, мессенджер (`/new`, первый чат) — текстом. Чаты объектов и импорт хук не спрашивают.
628
+ - `listChats` отдаёт `closed`: на экране и в `/chats` мессенджера у закрытого чата пометка.
629
+ - Сервер: запрещённое действие — `403 { error: { kind: "action_forbidden", action, code, message } }`, закрытый чат — `409 { error: { kind: "chat_closed", code, message } }`. Мессенджеры отвечают человеку текстом.
630
+ - Мессенджеры: голосовое при `voice: false` и файлы при `attachments: false` не скачиваются и не распознаются, `/chats` смотрит на `chatList`. Своя служба может реализовать необязательный `assertCapability`.
631
+ - Песочница: закрытие работает как в бою, а кнопка «Открыть чат снова» (`POST {sandboxBase}/chats/:chatId/reopen`, только админ) позволяет продолжить тест. Скину песочницы — необязательный `SandboxSkin.ChatReopen`, надписи `SandboxLabels.chatReopen` (необязательные); в Тандеме кнопка своя.
632
+ - Экран: кнопки по правам прячутся сами, после ответа 403 `action_forbidden` права перечитываются, закрытый чат блокирует поле и показывает текст закрытия. Голосовое сообщение при `voice: false` не уходит. Новое:
633
+ - `AppRefusalKind` — `chat_creation_refused`; `useChatList` — `onCreateRefused` и `creationRefusal`, `ChatWorkspace` отдаёт отказ в `onTurnRefused`;
634
+ - `ChatListEntry.closed` и `ChatListItemView.closed` — пометка закрытого чата в списке, надпись `chatListClosed` («Закрыт», обязательное поле `ChatLabels`);
635
+ - `reloadKey` у `AgentChat`, `useAgentChat` и `ChatWorkspace` — перечитать историю, когда чат изменили не через экран;
636
+ - `onChatUnavailable` у `AgentChat` — служебный колбэк для `ChatWorkspace`, приложению не нужен;
637
+ - `ChatWorkspace` при `chatList: false` не мигает списком: он появляется после первой истории (и при `chatList: true` — тоже с этой короткой задержкой) и потом не запрашивается;
638
+ - `onTurnRefused` получает новые виды отказов, `onChatAccessChange` сообщает права и закрытие, `ChatWorkspace` при `chatList: false` показывает только чат.
639
+ - Скин: договор v20 — необязательный `ChatClosedNotice` и необязательный `list` у `ChatListSkin.ChatWorkspace` ([docs/skin-v20.md](https://github.com/ykurilov/agent-chat-sdk/blob/main/docs/skin-v20.md)). Скин v19 работает без правок. В Тандеме — `TandemChatClosedNotice`.
640
+ - Хранилища: колонка `closed` у чатов, миграция SQLite и PostgreSQL применяется сама. Своё хранилище может реализовать необязательный `setChatClosed(chatId, closed | null)` и отдавать `closed` в `getChat`; общий тест — `describeChatStorageContract(…, { chatClosure: "required" })`.
641
+
642
+ ### Как обновить приложение
643
+
644
+ 1. Найдите в коде приложения исчерпывающие `switch` по виду ошибки сервера чата, по `refusal.kind` из `onTurnRefused` и по `reason` в `turnNotStarted`. Искать: `kind: "turn_refused"`, `case "turn_refused"`, `case "access_refused"`, `HandlerErrorKind`, `AppRefusalKind`, `TurnNotStartedReason`, `case "busy"`.
645
+ 2. Добавьте ветки для новых видов:
646
+
647
+ Было:
648
+
649
+ ```ts
650
+ switch (refusal.kind) {
651
+ case "turn_refused": return showLessonClosed(refusal.message);
652
+ case "access_refused": return showNoAccess(refusal.message);
653
+ default: { const never: never = refusal.kind; return never; }
654
+ }
655
+ ```
656
+
657
+ Стало:
658
+
659
+ ```ts
660
+ switch (refusal.kind) {
661
+ case "turn_refused": return showLessonClosed(refusal.message);
662
+ case "access_refused": return showNoAccess(refusal.message);
663
+ case "chat_closed":
664
+ case "action_forbidden": return; // чат уже показал текст сам
665
+ case "chat_creation_refused": return showToast(refusal.message);
666
+ default: { const never: never = refusal.kind; return never; }
667
+ }
668
+ ```
669
+
670
+ В `switch` по `reason` в `turnNotStarted` добавьте `case "chat_closed":` рядом с `case "busy":`: резерв снимается так же.
671
+
672
+ 3. Свой словарь `ChatLabels`, собранный целиком (не поверх `russianChatLabels`): добавьте `chatListClosed: "Закрыт"`.
673
+ 4. Своё хранилище чата (не SQLite и не PostgreSQL из SDK): если нужен закрытый чат, реализуйте `setChatClosed` и отдавайте `closed` в `getChat`. Без этого `closeChat` бросает ошибку, а инструменты не получают `model.closeChat`.
674
+ 5. Проверка: `pnpm typecheck` проходит, чат открывается и отвечает.
675
+
676
+ Заметка к 0.28: закрытие станет записью хода, и правка, «Переделать» и ветки в закрытом чате станут возможны. Чтобы сохранить нынешнее поведение, задайте права: `chatCapabilities: ({ chat }) => chat.closed ? { edit: false, regenerate: false, branches: false } : {}`.
677
+
678
+ ### Patch Changes
679
+
680
+ - Updated dependencies [d8341b2]
681
+ - Updated dependencies [5e4ed28]
682
+ - Updated dependencies [e0bb519]
683
+ - @ykurilov/agent-chat-core@0.27.0
684
+
685
+ ## 0.26.2
686
+
687
+ ### Patch Changes
688
+
689
+ - Updated dependencies [f24858a]
690
+ - @ykurilov/agent-chat-core@0.26.2
691
+
692
+ ## 0.26.1
693
+
694
+ ### Patch Changes
695
+
696
+ - @ykurilov/agent-chat-core@0.26.1
697
+
698
+ ## 0.26.0
699
+
700
+ ### Minor Changes
701
+
702
+ - eb43e47: Пределы хода и честная остановка сервера. Без новых опций запрос к модели прежний. Поменялось одно: `flush()` теперь
703
+ закрывает идущие ответы как «Стоп», а не оставляет их «идущими» до следующего старта.
704
+
705
+ - **Общий срок хода** — `agent.turnTimeoutMs`: число мс или `{ default?, byModel? }`. Срок вышел — ход останавливается,
706
+ как по «Стопу»: написанное цело, `status: "stopped"`, а `afterTurn` получает `stopReason: "timeout"`. «Стоп» человека —
707
+ `stopReason: "user"`, остановка сервера — `"shutdown"`. 0 или `Infinity` — без срока. Иначе от 1000 до 2³¹−1. Модель из
708
+ `byModel`, которой нет в каталоге, — ошибка при создании службы. Не задано — без срока. Срок считается до записи итога:
709
+ `afterTurn` записанного ответа он не обрывает.
710
+ - **Предел токенов ответа** — `agent.maxOutputTokens`: целое от 1 или `{ default?, byModel? }`. Уходит провайдеру в каждом
711
+ запросе к модели (в каждом шаге хода). Ответ упёрся — `afterTurn` получает `lengthCut: true`. Не задано — предела в
712
+ запросе нет, кэш модели не меняется.
713
+ - **`turnNotStarted`** — новый хук службы и `createChatHandler`: `({ userId, chat, action, messageId?, text?, reason })`. Он
714
+ зовётся, когда `beforeTurn` пропустил ход, а ход не начался. Причины: `busy`, `too_many_turns`, `spending_limit`,
715
+ `duplicate_message`, `invalid`, `no_turn`, `error`. Каждый пропуск `beforeTurn` закрывается ровно одним: `afterTurn`
716
+ начатого хода или `turnNotStarted`. Так резерв приложения больше не повисает. Ответ клиенту прежний (409, 429…), бросок
717
+ хука уходит в `logError`. Порядок проверок прежний: отказ приложения (403) по-прежнему приходит раньше «занято» (409).
718
+ - **`beforeTurn` получает `messageId`** — id сообщения человека у `send` и `edit`.
719
+ - **`flush({ waitForAnswersMs })`** у службы, `createChatHandler` и песочницы. Пока идёт остановка, новые ходы не
720
+ начинаются: чат занят. Идущие ответы ждутся до срока (по умолчанию 0). Не успевшие закрываются как «Стоп»: написанное
721
+ записывается, обрывков нет, `afterTurn` видит `stopReason: "shutdown"`. `flush` возвращается, когда все ответы записаны и
722
+ хуки `afterTurn` (в своём сроке), сжатие, память и фон закончены. Только после этого можно закрывать хранилище. Неверный
723
+ срок — `TypeError`. Остановленный ход, который за 10 с так и не записал итог (база висит), `flush` больше не ждёт:
724
+ строка в `logError`, ответ прервёт восстановление при следующем старте. Не прошла конечная запись — ответ помечен
725
+ брошенным, и любая копия прерывает его сразу, без ожидания порога.
726
+ - Новые типы: `TurnTimeoutSetting`, `MaxOutputTokensSetting`, `TurnNotStartedHandler`, `TurnNotStartedReason`,
727
+ `TurnStopReason`, `FlushOptions`. В `TurnResult` — `stopReason?`, `lengthCut?`.
728
+
729
+ ### Как обновить приложение
730
+
731
+ 1. Остановка сервера (обработчик `SIGTERM`, где зовётся `chat.flush()`): дайте ответам доработать.
732
+
733
+ Было:
734
+
735
+ ```ts
736
+ server.close();
737
+ await chat.flush();
738
+ await storage.close();
739
+ ```
740
+
741
+ Стало:
742
+
743
+ ```ts
744
+ server.close();
745
+ await chat.flush({ waitForAnswersMs: 20_000 }); // ждёт ответы и afterTurn; не успевшие — «Стоп» с записью
746
+ await storage.close();
747
+ ```
748
+
749
+ Без опций `flush()` тоже работает, но идущие ответы останавливаются сразу (`status: "stopped"`, раньше они становились
750
+ `interrupted` при следующем старте). Если приложение ждало `interrupted` после остановки, ждите `stopped` со
751
+ `stopReason: "shutdown"`.
752
+ 2. Если `beforeTurn` резервирует что-то (попытку, деньги), снимайте резерв в `turnNotStarted`. Связать резерв с ходом
753
+ удобно по `messageId`:
754
+
755
+ ```ts
756
+ beforeTurn: async ({ userId, messageId }) => { await reserveAttempt(userId, messageId); },
757
+ turnNotStarted: async ({ userId, messageId }) => { await releaseAttempt(userId, messageId); },
758
+ ```
759
+ 3. По желанию задайте пределы хода в `agent`:
760
+
761
+ ```ts
762
+ agent: { turnTimeoutMs: 180_000, maxOutputTokens: { default: 4_000, byModel: { "deepseek-4.1-flash": 2_000 } } },
763
+ ```
764
+
765
+ В `afterTurn` отличайте `stopReason` (`user` / `timeout` / `shutdown`) и `lengthCut`.
766
+ 4. Проверка: `pnpm typecheck` проходит. Остановите сервер посреди ответа: в базе ответ `stopped`, а `afterTurn` получил
767
+ `stopReason: "shutdown"`.
768
+
769
+ ### Patch Changes
770
+
771
+ - Updated dependencies [eb43e47]
772
+ - @ykurilov/agent-chat-core@0.26.0
773
+
774
+ ## 0.25.0
775
+
776
+ ### Minor Changes
777
+
778
+ - 99d426e: Приветствие по указанию: `greeting: { instruction }` в `createChat`, `openSubjectChat` и ответе `subjectChats`. При
779
+ создании чата SDK один раз будит агента служебным сообщением `conversation_started`, и агент пишет первым сам. Ровно одно
780
+ из `text` и `instruction`. Найденный чат не будится; сбой побудки — в `logError`, чат всё равно отдаётся.
781
+
782
+ ### Patch Changes
783
+
784
+ - Updated dependencies [99d426e]
785
+ - Updated dependencies [d8201f6]
786
+ - @ykurilov/agent-chat-core@0.25.0
787
+
788
+ ## 0.24.0
789
+
790
+ ### Minor Changes
791
+
792
+ - 9b895cd: Ломает приложения: только исчерпывающие `switch` по видам ошибок сервера и событиям чата (новые значения) и код, который
793
+ сам читает ответ `resolveUser`. Без новых настроек поведение прежнее.
794
+
795
+ Агент внутри продукта: агент работает с объектами приложения — уроком, заказом, документом. Всё по желанию; без новых
796
+ настроек запрос к модели не меняется ни на байт. Подробно — `docs/in-product-agent.md`.
797
+
798
+ - **Проверка перед ходом.** `beforeTurn({ userId, chat, action, text? })` отказывает своими словами: 403 `turn_refused`
799
+ до любых записей и лимитов, на отправке, правке, повторе, переделке, подтверждении, виджете, служебных сообщениях, фоновой
800
+ задаче и в мессенджерах. `resolveUser` может вернуть `{ refused: { code, message, status? } }` → `access_refused` на всех
801
+ адресах чата, голоса, голосового режима (WebSocket 4401) и мессенджеров. React: `onTurnRefused`, текст отказа вместо «нет
802
+ связи», сообщение возвращается в поле. Ручная проверка — `service.assertTurnAllowed`.
803
+ - **Итог хода.** `afterTurn(turn)` после каждого хода с повторами и сроком (`afterTurnAttempts`, `afterTurnTimeoutMs`),
804
+ переживает перезапуск сервера. `TurnResult`: владелец и автор хода, текст, части, расход, действие, канал, объект,
805
+ сотрудник. События приложения `{ type, data? }` — из хука, из инструмента (`model.emitAppEvent`) и вне хода
806
+ (`service.publishAppEvent`); в React `appEvents` и `onAppEvent`, события переживают перезагрузку. Модели не уходят.
807
+ - **Чат объекта.** Ключ и данные объекта у чата, один чат на ключ у человека: `service.openSubjectChat`, на сервере
808
+ `subjectChats` (`POST …/subject`), в React `<AgentChat subject="lesson:42" />`. Данные объекта видят `beforeTurn`,
809
+ инструменты (`model.subject`) и `afterTurn`; в браузер и модели не уходят.
810
+ - **Приветствие.** `greeting: { text }` — первое сообщение от агента без вызова модели. Проверено на всех моделях каталога.
811
+ - **Лимит сообщений.** Окно лимитов с `limitMessages` вместо `limitUsd`, `exemptUsers` — люди без лимитов,
812
+ `onMessageThreshold` — пороги. Отказ — 429 с `unit: "messages"`.
813
+ - **Импорт старой переписки.** `service.importChat({ ownerId, chat, messages })` с датами и картинками
814
+ (`attachments.importFile`), без хода, памяти, расхода и лимитов.
815
+ - **Сотрудник проверяет чат человека.** Песочница с опцией `people`: админ видит чаты человека только на чтение и
816
+ проверяет ответы в копии у себя. Память человека в копии только читается, расход — на админа, человек ничего не видит.
817
+ Инструменты приложения получают `model.staff` и в копии в данные человека не пишут.
818
+ - **Свои кнопки у поля ввода.** `composerActions` с помощником `{ value, setValue, send, addFiles, busy, blocked, canSend }`.
819
+ Договор скина v19: `Composer.actions`; скин v18 работает без кнопок, Тандем готов.
820
+ - **Хранилища.** Миграции применяются сами: SQLite `0015`, PostgreSQL `0014` (ключ объекта, состояние хука итога).
821
+
822
+ ### Что может сломаться
823
+
824
+ - **Исчерпывающие `switch` по закрытым спискам.** Новые значения: вид ошибки сервера `turn_refused` и `access_refused`
825
+ (`HandlerErrorKind`), события чата `after-turn-failed` и `app-event` (`ChatEvent`), `unit` у отказа лимита. Добавьте ветки
826
+ и надписи.
827
+ - **Список чатов без чатов объектов.** `listChats` и `GET {basePath}` по умолчанию не показывают чаты с ключом объекта.
828
+ Касается только приложений, которые начнут ими пользоваться; показать — `chatList: { subjectChats: true }` или
829
+ `listChats(userId, { subjects: "include" })`.
830
+ - **Свой `resolveUser` в обёртках.** Тип ответа — `ResolvedUser` (`{ id } | { refused } | null`). Код, который сам вызывает
831
+ `resolveUser` и читает `.id`, сначала проверяет `"refused" in user`.
832
+ - **Своё хранилище.** Для новых возможностей нужны необязательные методы (`findChatBySubject`, `importChat`,
833
+ `setAfterTurnState`, `listPendingAfterTurn`); без них эти возможности при включении дают ошибку при старте, остальное
834
+ работает. `listChats(ownerId, options?)` получил второй параметр — чаты с ключом скрывать по умолчанию.
835
+ - **Свой `ChatService`** (заглушки в тестах) добавляет новые методы: `openSubjectChat`, `importChat`, `assertTurnAllowed`, `peekHistory`,
836
+ `publishAppEvent`.
837
+ - **Своя реализация `SpendingLimits`** может добавить `takeMessage` — без него сообщения не считаются.
838
+ - **`AgentChat`: `chatId` стал необязательным** — нужен ровно один из `chatId` и `subject`; ни одного или оба — ошибка при
839
+ рендере. При смене `chatId` чат пересоздаётся: черновик и состояние прошлого чата не переносятся.
840
+ - **Окна лимитов: `limitUsd` стал необязательным** (у окна сообщений его нет). Код, который читает свои окна, проверяет
841
+ `limitMessages`.
842
+ - **Отказ сервера в React** (`ServerRefusal`) получил `kind` и `code`. 401 со словами приложения (`access_refused`) — это
843
+ отказ, а не «вход истёк».
844
+ - **«Пустой чат»** для «Нового чата»: чат объекта пустым не считается, обычный чат с одним приветствием — считается.
845
+ - Новый подпуть `@ykurilov/agent-chat-core/access` — помощники отказа в доступе для своих обработчиков.
846
+
847
+ ### Как обновить приложение
848
+
849
+ 1. `pnpm up "@ykurilov/agent-chat-*@^0.24.0"`. Миграции базы применяются сами при открытии хранилища.
850
+ 2. Исчерпывающие `switch` по `HandlerErrorKind` (сервер) и `ChatEvent` (`chatEvents` ядра): найдите их поиском и добавьте
851
+ ветки `turn_refused`, `access_refused`, `after-turn-failed`, `app-event`. Обязательных надписей не прибавилось: текст
852
+ отказа приходит с сервера. Свой словарь для лимита сообщений — необязательные `spendingMessagesLeft`,
853
+ `spendingMessagesExhausted`, `editMessageLimit`.
854
+
855
+ Было: `switch (event.type) { case "tasks-changed": … case "notice-added": … default: assertNever(event) }`
856
+ Стало: те же ветки плюс `case "after-turn-failed": case "app-event": break;`
857
+ 3. Свой код, который сам зовёт `resolveUser` и читает `.id`: сначала `if (user && "refused" in user) …`.
858
+ 4. Ничего больше не нужно, если новые возможности не включать. Чтобы включить — `docs/in-product-agent.md`.
859
+ 5. Свой скин: кнопки у поля появятся, когда `Composer` начнёт рисовать `actions` (договор v19, `docs/skin-v19.md`).
860
+ 6. Проверка: `pnpm typecheck` без ошибок, чат открывается и отвечает как раньше.
861
+
862
+ ### Patch Changes
863
+
864
+ - Updated dependencies [9b895cd]
865
+ - @ykurilov/agent-chat-core@0.24.0
866
+
867
+ ## 0.23.0
868
+
869
+ ### Patch Changes
870
+
871
+ - @ykurilov/agent-chat-core@0.23.0
872
+
873
+ ## 0.22.0
874
+
875
+ ### Patch Changes
876
+
877
+ - @ykurilov/agent-chat-core@0.22.0
878
+
879
+ ## 0.21.0
880
+
881
+ ### Patch Changes
882
+
883
+ - Updated dependencies [7c3bcc4]
884
+ - @ykurilov/agent-chat-core@0.21.0
885
+
886
+ ## 0.20.0
887
+
888
+ ### Minor Changes
889
+
890
+ - fd16b01: Помощники, фоновые задачи и мессенджеры. Ничего не ломается: без новых настроек всё работает как раньше.
891
+
892
+ - `agent.helpers` в `createChatHandler` / `createChatServer`: узкие агенты со своей моделью и инструментами; основной агент зовёт их как `helper_<имя>` и получает только итог. Человек видит все шаги помощника. Подробности: `docs/helpers.md`.
893
+ - `background` в `createChatHandler` / `createChatServer` принимает объект из нового пакета `@ykurilov/agent-chat-background`; тогда появляются адреса `{base}/:chatId/tasks` (список, отмена, ответ на подтверждение). Помощник с `background: true` без этого пакета — ошибка при запуске.
894
+ - `createChat(…, { origin })` помечает чат, созданный из мессенджера (`telegram`, `vk`, `max`); пометка видна в списке чатов.
895
+
896
+ Что сделать в приложении: ничего обязательного. Хотите помощников — добавьте `agent.helpers`. Хотите фон или мессенджеры — поставьте новые пакеты `@ykurilov/agent-chat-background` и `@ykurilov/agent-chat-messengers` (см. их README).
897
+
898
+ ### Patch Changes
899
+
900
+ - Updated dependencies [fd16b01]
901
+ - @ykurilov/agent-chat-core@0.20.0
902
+
903
+ ## 0.19.0
904
+
905
+ ### Minor Changes
906
+
907
+ - aa0fd51: Гибкие лимиты расходов. Вместо одного бюджета на месяц можно задать любой набор окон сразу: сутки, неделю или месяц по
908
+ часовому поясу (со своим днём начала недели), «N часов от первого запроса» или на всё время. Область окна — человек, чат или
909
+ весь сервис. Ход начинается, только если не исчерпано ни одно окно.
910
+
911
+ - `createSpendingLimits({ windows: [...] })` вместо `budget` и `period`. Прежний `budget` работает как раньше (и получил
912
+ `period: "week"`).
913
+ - `carryOverrun: true` у окна: перерасход начатого хода переносится в начало следующего окна.
914
+ - `turn: { minRemainingUsd, maxCostUsd, maxToolCalls, overrun }` — запас на старт хода и потолки одного хода. На потолке
915
+ ход заканчивается итогом без новых вызовов инструментов.
916
+ - Человеку: свой лимит окна, блокировка (`setUserSettings`, `getUserSettings`) и ручной сброс окна (`resetWindow`).
917
+ - `onThreshold(event)` — событие при 80% и 100% лимита окна (пороги — `thresholds`), одно на порог в окне, проверка после
918
+ каждой записи расхода.
919
+ - `getSpending` отдаёт доллары по каждому окну (`windows`) и `blocked`.
920
+ - Сервер: в ответе 429 `spending_limit_reached` появились `reason` (`limit` | `blocked` | `reserve`), `windowId` и `hours`.
921
+ `GET …/spending` отдаёт `reason`, `windowId`, `hours`, `blocked` и список окон `windows` — только доли и время сброса,
922
+ без долларов.
923
+ - Браузер: надписи «на неделю», «на 5 ч» (с временем обновления), «сервиса», «Лимита … не хватит на новый ответ», «Новые
924
+ ответы для вас приостановлены». В `SpendingInfo` новые необязательные поля `hours`, `reason`, `windowId`, `windows`.
925
+
926
+ Скользящим окнам, личным настройкам, сбросу и порогам нужны методы состояния хранилища: в памяти, SQLite и PostgreSQL они есть
927
+ (запись `storage-limit-state`). Подробно — README пакета `@ykurilov/agent-chat-limits`, раздел «Окна лимитов».
928
+
929
+ **Ломает приложения:** только исчерпывающие проверки и свои словари по периоду. У `SpendingScope` появилось значение
930
+ `service`, у `SpendingPeriod` — `week` и `rolling` (в `@ykurilov/agent-chat-core` и `@ykurilov/agent-chat-react`).
931
+
932
+ ### Как обновить приложение
933
+
934
+ 1. Поищите в приложении `SpendingPeriod`, `SpendingScope`, `info.period`, `info.scope` и `spendingWarning` / `spendingExhausted`
935
+ в своём словаре надписей. Если надпись строится по таблице периодов, добавьте новые значения:
936
+
937
+ Было:
938
+
939
+ ```ts
940
+ const periodPhrase = { month: " на месяц", day: " на день", none: "" };
941
+ ```
942
+
943
+ Стало:
944
+
945
+ ```ts
946
+ const periodPhrase = { month: " на месяц", week: " на неделю", day: " на день", none: "" };
947
+ const phrase = info.period === "rolling" ? ` на ${info.hours} ч` : periodPhrase[info.period];
948
+ ```
949
+
950
+ То же для области: `info.scope === "service"` — лимит всего сервиса.
951
+ 2. Свой словарь с `spendingExhausted`: учтите `info.reason` — `"blocked"` (человек заблокирован, `resetsAt` нет) и `"reserve"`
952
+ (остаток меньше запаса на старт хода).
953
+ 3. Свой разбор ответа 429 или `GET …/spending` на сервере или в браузере: новые поля необязательные, старые на месте.
954
+ 4. Ничего из этого нет в приложении — обновление ничего не требует.
955
+ 5. Проверка: `pnpm typecheck` проходит; с исчерпанным лимитом чат показывает надпись и не даёт отправить сообщение.
956
+ - 05e258b: Сервер сообщает приложению, что ход агента закончен: необязательный `onTurnFinished` в настройках `createChatHandler`
957
+ (и `createChatService`) получает `{ userId, chatId, messageId, status, modelId, providerId }` один раз на ход при любом
958
+ итоге — законченном, остановленном, с ошибкой, прерванном или ждущем человека. Итог к этому мигу уже записан. Ход
959
+ обработчик не ждёт; если обработчик бросил или его обещание отклонено, ответ не ломается, сбой уходит в `logError`.
960
+ Тип — `TurnFinished` из `@ykurilov/agent-chat-core`. Без `onTurnFinished` всё как раньше.
961
+
962
+ ### Patch Changes
963
+
964
+ - Updated dependencies [fde2297]
965
+ - Updated dependencies [aa0fd51]
966
+ - Updated dependencies [9f94dbe]
967
+ - Updated dependencies [2013f15]
968
+ - Updated dependencies [aa0fd51]
969
+ - Updated dependencies [05e258b]
970
+ - Updated dependencies [aa0fd51]
971
+ - @ykurilov/agent-chat-core@0.19.0
972
+
973
+ ## 0.18.6
974
+
975
+ ### Patch Changes
976
+
977
+ - @ykurilov/agent-chat-core@0.18.6
978
+
979
+ ## 0.18.5
980
+
981
+ ### Patch Changes
982
+
983
+ - @ykurilov/agent-chat-core@0.18.5
984
+
985
+ ## 0.18.4
986
+
987
+ ### Patch Changes
988
+
989
+ - @ykurilov/agent-chat-core@0.18.4
990
+
991
+ ## 0.18.3
992
+
993
+ ### Patch Changes
994
+
995
+ - @ykurilov/agent-chat-core@0.18.3
996
+
997
+ ## 0.18.2
998
+
999
+ ### Patch Changes
1000
+
1001
+ - @ykurilov/agent-chat-core@0.18.2
1002
+
1003
+ ## 0.18.1
1004
+
1005
+ ### Patch Changes
1006
+
1007
+ - @ykurilov/agent-chat-core@0.18.1
1008
+
1009
+ ## 0.18.0
1010
+
1011
+ ### Minor Changes
1012
+
1013
+ - 43d7ebe: Песочница агента для админа: новый пакет `@ykurilov/agent-chat-sandbox`. Админ говорит с боевым агентом и видит журнал:
1014
+ каждое обращение к модели (что ушло, что пришло, токены, кэш, цена) и каждый вызов инструмента. Любое сообщение можно
1015
+ поправить (текст и результаты обычных инструментов), правка создаёт ветку. Модель и системная инструкция меняются для одного
1016
+ чата, боевые не трогаются. Память в песочнице только на чтение.
1017
+
1018
+ - сервер: `createSandboxHandler({ chat, isAdmin, journal })`, журнал `createSqliteSandboxJournal` (`/sqlite`) или
1019
+ `createPostgresSandboxJournal` (`/postgres`, схема `agent_chat`), хранится 30 дней; не админ получает 404 на любом адресе;
1020
+ - страница: `AgentSandbox` из `@ykurilov/agent-chat-sandbox/react`, готовый скин `tandemSandboxSkin`;
1021
+ - `createChatServer` — новый экспорт server (на нём стоят и боевой, и песочный обработчик);
1022
+ - core: `turnObserver`, `chatInstructions`, `lockedToolOutputs`, `editAnswer`; боевой запрос к модели остаётся прежним побайтно;
1023
+ - договор скина чата версии 15: необязательные `AgentMessage.onEdit/edited` и `ToolCallRow.outputEdited`; новые надписи
1024
+ `answerEdited`, `toolOutputEdited`, `editAnswer`; договор скина песочницы `SandboxSkin` версии 1.
1025
+
1026
+ Подробно: `docs/sandbox.md`.
1027
+
1028
+ ### Как обновить приложение
1029
+
1030
+ Обычному приложению делать ничего не нужно: песочница включается отдельно, миграций боевой базы нет (таблицы `sandbox_*` создаёт
1031
+ сам журнал).
1032
+
1033
+ 1. Полный словарь надписей, написанный вручную без `{ ...russianLabels }`, — добавьте `answerEdited`, `toolOutputEdited`, `editAnswer`.
1034
+ 2. Свой скин версии 14 работает без правок; чтобы показать «изменено» и кнопку правки, реализуйте необязательные части версии 15.
1035
+ 3. Своя реализация сервера поверх `createChatHandler` продолжает работать; для песочницы возьмите `createChatServer`.
1036
+ 4. Проверка: `pnpm typecheck`, затем в чате отправить сообщение и получить ответ.
1037
+
1038
+ ### Patch Changes
1039
+
1040
+ - Updated dependencies [43d7ebe]
1041
+ - @ykurilov/agent-chat-core@0.18.0
1042
+
1043
+ ## 0.17.0
1044
+
1045
+ ### Patch Changes
1046
+
1047
+ - @ykurilov/agent-chat-core@0.17.0
1048
+
1049
+ ## 0.16.0
1050
+
1051
+ ### Minor Changes
1052
+
1053
+ - cf52f10: **Ломает только исчерпывающие проверки:** у `UsageSource` появилось значение `images`, у видов ошибок сервера — `widget_not_pending` и `invalid_answer`, у `ChatService` — метод `answerWidget`. Своё хранилище должно поддержать условную запись `AwaitingMessagePatch.expectedParts` (при несовпадении частей — `MessageNotAwaitingError`; помощник `sameMessageParts` из core), иначе две копии сервера могут затереть ответы друг друга; общие тесты хранилищ это проверяют. Остальное необязательно, приложение без правок работает как раньше.
1054
+
1055
+ Что нового: агент может спросить кнопками, звёздами или формой (виджеты), нарисовать схему блоком ` ```mermaid ` и создать картинки через Runware. Всё включается отдельно:
1056
+
1057
+ - виджеты: `agent: { widgets: { basic: true, custom: [свойВиджет] } }`, свой виджет — `defineWidget` из core и компонент в `widgets={{ … }}` у `AgentChat`; ответ человека идёт на `POST /:chatId/widgets/:toolCallId`, ход ждёт со статусом `awaiting_approval`;
1058
+ - схемы: новый пакет `@ykurilov/agent-chat-diagrams`, `agent: { diagrams: true }` на сервере и `diagrams={diagramsClient}` на странице;
1059
+ - картинки: новый пакет `@ykurilov/agent-chat-images` (нужен пакет вложений и `RUNWARE_API_KEY`), `images.tools(model)` в `agent.tools`, `images={imagesClient}` на странице; свой срок ожидания (210 с) инструмент сообщает ядру сам (`declareToolTimeout`), расход бронируется до заказа (`assertWithinLimit({ pendingUsd })`);
1060
+ - окно просмотра картинок и схем: Тандем готов, своему скину — необязательные части договора версии 13 (`Widget`, `SuggestedReplies`, `GeneratedImages`, `MediaViewer`, `diagramColors`, `renderDiagram`). `splitMermaidBlocks` экспортируется из react;
1061
+ - вложения: `Attachments.storage` и `detectFileKind` экспортируются из пакета вложений;
1062
+ - голосовой режим произносит вопрос и варианты ждущего виджета и говорит «схема на экране» вместо блока схемы;
1063
+ - «Стоп» на экране закрывает висящие вызовы так же, как сервер, разрешённые без результата — «результат неизвестен» (`stoppedToolCallReasons.resultUnknown`);
1064
+ - упавший свой виджет страницы заменяется запасным видом скина, лента цела.
1065
+
1066
+ Подробно: `docs/widgets.md`, `docs/diagrams.md`, `docs/images.md`, `docs/media-viewer.md`.
1067
+
1068
+ ### Как обновить приложение
1069
+
1070
+ Обычному приложению на SQLite или PostgreSQL из набора делать ничего не нужно: миграций нет, всё новое включается отдельно.
1071
+
1072
+ 1. Исчерпывающий `switch` по `UsageSource` — добавьте ветку `"images"`; по видам ошибок сервера — `"widget_not_pending"` и `"invalid_answer"`.
1073
+ 2. Своя реализация `ChatService` — добавьте метод `answerWidget({ chatId, ownerId, toolCallId, answer, page }): Promise<{ resumed: boolean }>` (тип `WidgetAnswerRequest` из core, образец — служба из core).
1074
+ 3. Своё хранилище: `updateAwaitingMessage` получает необязательное поле `expectedParts`. Если оно есть и части сообщения в базе с ним не совпадают (`sameMessageParts` из core), бросайте `MessageNotAwaitingError`; проверку и запись делайте в одной транзакции. Прогоните общие тесты хранилищ из core.
1075
+ 4. Своя обёртка `assertWithinLimit` из limits — пробрасывайте новое необязательное поле `pendingUsd`.
1076
+ 5. Проверка: `pnpm typecheck`, затем в чате отправить сообщение и получить ответ.
1077
+
1078
+ ### Patch Changes
1079
+
1080
+ - Updated dependencies [cf52f10]
1081
+ - @ykurilov/agent-chat-core@0.16.0
1082
+
1083
+ ## 0.15.0
1084
+
1085
+ ### Minor Changes
1086
+
1087
+ - f05ad23: Часовой пояс для лимитов расходов: `createSpendingLimits({ timeZone: "Europe/Moscow" })`. Сутки и месяцы режутся
1088
+ по полуночи этого пояса, а не по UTC (раньше московский суточный лимит обнулялся в 03:00). Переход на летнее время
1089
+ учтён: сутки бывают 23 и 25 часов. Для сегодняшней даты агента этот пояс только запасной: главнее пояс браузера
1090
+ человека, а пояс лимитов берётся, когда браузер свой пояс не прислал. Дата обновления лимита на экране — по поясу лимитов. Пояс не из базы IANA — ошибка при старте.
1091
+
1092
+ Новое: `timeZone` в настройках лимитов и необязательное поле `timeZone` у `SpendingLimits`, `SpendingStatus`,
1093
+ `SpendingLimitReachedError`, `SpendingReport` и `SpendingInfo` (экран); ответы `GET …/spending` и отказ 429 получают
1094
+ `timeZone`, если пояс задан. Ядро экспортирует `resolveTimeZone`.
1095
+
1096
+ ### Как обновить приложение
1097
+
1098
+ Ничего делать не нужно: без `timeZone` лимиты по UTC, дата агента по поясу человека или сервера, как раньше.
1099
+
1100
+ Чтобы считать сутки по Москве, в файле, где создаются лимиты:
1101
+
1102
+ ```ts
1103
+ const limits = createSpendingLimits({ storage, catalog, budget, period: "day", timeZone: "Europe/Moscow" });
1104
+ ```
1105
+
1106
+ `agent.timeZone` после этого задавать не нужно. Если он уже задан, он должен совпадать с поясом лимитов (другое имя того же пояса
1107
+ вроде `Asia/Calcutta` и `Asia/Kolkata` — совпадение), иначе
1108
+ служба чата не создастся (ошибка называет оба пояса). Свои надписи `spendingExhausted`, которые сами форматируют
1109
+ `resetsAt`, могут брать пояс из `info.timeZone`. Проверка: `limits.getSpending({ userId })` отдаёт `periodStart`
1110
+ вида `…T21:00:00.000Z` (полночь по Москве).
1111
+
1112
+ ### Patch Changes
1113
+
1114
+ - Updated dependencies [f05ad23]
1115
+ - Updated dependencies [f05ad23]
1116
+ - @ykurilov/agent-chat-core@0.15.0
1117
+
1118
+ ## 0.14.0
1119
+
1120
+ ### Minor Changes
1121
+
1122
+ - 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` или имя, совпавшее с инструментом приложения или памяти, — ошибка при создании службы. Вкладка исполняет каждый вызов чата один раз, в том числе после перезагрузки. Ожидание живёт в памяти процесса: как и продолжение ответа, работает на одном сервере.
1123
+
1124
+ **Ломает приложения** только там, где сами реализуют интерфейс `ChatService` (например, поддельная служба в тестах): в него добавился метод `answerBrowserCall`.
1125
+ - 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)` приложений ничего менять не нужно.
1126
+ - 656233a: Долгая память агента. Агент сам запоминает устойчивое о человеке (предпочтения, правила общения, факты о работе) и помнит это в новых чатах. В новом пакете `@ykurilov/agent-chat-memory` лежат инструмент `manage_memory`, правила записей и блок памяти для запроса. Человек видит записи на экране памяти: может добавить, исправить, удалить запись или стереть всю память. Записи у каждого человека свои, удаление чата их не трогает. Раз в сутки после ответа та же модель предлагает слить повторные записи (опция `optimizer`); код проверяет группы и применяет годные, на экране появляется раздел «Объединено» с кнопкой «Вернуть как было». Отрицание не теряется («не любит кофе» не склеивается с «любит кофе»). После сбоев проверка ждёт дольше: час, 6 часов, сутки. Пределы записей и дубли держатся при одновременных действиях (замок на человека, а в SQLite и PostgreSQL ещё и проверка внутри транзакции), в том числе при возврате склейки. Память не меняет начало запроса к модели, поэтому кэш модели сохраняется.
1127
+
1128
+ **Ломает приложения** только с полным словарём надписей `ChatLabels`, написанным вручную (шаг 5): в него добавились надписи `memory…`. Остальным обновление ничего не ломает: без опции `memory` чат работает как раньше. Чтобы включить память, выполните шаги ниже. Новые адреса: `/api/chat/memory/merges` и `/api/chat/memory/merges/:runId/revert`. Миграции SQLite `0009` и `0010` и PostgreSQL `0008` и `0009` применяются сами при открытии базы (`0010` и `0009` — задел под склейку повторов записей).
1129
+
1130
+ ### Как обновить приложение
1131
+
1132
+ 1. **Включить память на сервере** (по желанию). Пакет ставится отдельно: `pnpm add @ykurilov/agent-chat-memory`. В файле, где создаётся `createChatHandler`:
1133
+
1134
+ Было:
1135
+
1136
+ ```ts
1137
+ createChatHandler({ basePath: "/api/chat", storage, resolveUser /* … */ });
1138
+ ```
1139
+
1140
+ Стало:
1141
+
1142
+ ```ts
1143
+ import { createLongTermMemory } from "@ykurilov/agent-chat-memory";
1144
+
1145
+ createChatHandler({ basePath: "/api/chat", storage, resolveUser, memory: createLongTermMemory({ storage }) /* … */ });
1146
+ ```
1147
+
1148
+ Инструкция агента и инструмент `manage_memory` добавляются сами: в `agent.instructions` и `agent.tools` ничего дописывать не нужно. Имя `manage_memory` занято: своего инструмента с таким именем быть не должно (иначе ошибка при создании). Адреса `/api/chat/memory` появляются сами. Без `memory` они отвечают `404`.
1149
+ 2. **Подключить экран памяти** (по желанию). Найдите страницу, куда его поставить, и добавьте компонент; `createMemoryApi` вызывайте один раз (например, в `useMemo`):
1150
+
1151
+ ```tsx
1152
+ import { AgentMemory, createMemoryApi } from "@ykurilov/agent-chat-react";
1153
+ import { tandemMemorySkin } from "@ykurilov/agent-chat-skin-tandem";
1154
+
1155
+ const memoryApi = useMemo(() => createMemoryApi({ api: "/api/chat" }), []);
1156
+ <AgentMemory api={memoryApi} skin={tandemMemorySkin} />
1157
+ ```
1158
+ 3. **Подпись вызова в ленте.** Было: `labels={russianLabels}`. Стало: `labels={withMemoryToolTitles(russianLabels)}`, где `withMemoryToolTitles` — из `@ykurilov/agent-chat-memory/labels`. Вместо `manage_memory` в ленте будет «Работа с памятью».
1159
+ 4. **Свой скин (договор скина версии 11).** `ChatSkin` и `ChatListSkin` не менялись. Экрану памяти нужен отдельный `MemorySkin` из пяти компонентов (`MemoryScreen`, `MemoryGroup`, `MemoryNoteRow`, `MemoryNoteEditor`, `MemoryConfirm`). Скин Тандема даёт его готовым: `tandemMemorySkin`. Описание — `specs/017-long-term-memory/contracts/memory.md`, раздел 5.
1160
+ 5. **Полный словарь надписей.** Если приложение задаёт объект `ChatLabels` целиком, а не `{ ...russianLabels, … }`, добавьте новые надписи: все ключи, которые начинаются с `memory` (`memoryHeading`, `memoryAdd`, `memoryClearAll` и другие, полный список — тип `ChatLabels` в `@ykurilov/agent-chat-react`). Без этого `pnpm typecheck` не пройдёт.
1161
+ 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` проверят их сами.
1162
+ 7. **Проверка.** `pnpm typecheck` и сборка проходят. Напишите агенту «Запомни, что я пишу на TypeScript», откройте экран памяти: там запись в разделе, который выбрал агент. В новом чате спросите, что он про вас знает.
1163
+ - aba59fe: Цикл агента и учёт расхода крепче (022, этап 8). Когда шаги хода кончаются, последний запрос идёт без инструментов (счёт по шагам всего хода, ход из одного шага не ограничивается) и с пометкой «подведи итог» в самом конце: ход кончается сводкой для человека, а не обрывом на вызове. Новая настройка `agent.sequentialTools` (`true` или список имён) пускает инструменты с последствиями по одному: «спиши деньги» не пойдёт одновременно с другим таким вызовом; очередь одна на чат (во всех ходах и повторах), ожидание очереди не съедает срок инструмента, «Стоп» отменяет ждущий вызов, и модель видит, что он не запускался. Оборванный вызов инструмента теперь говорит модели, что действие могло выполниться частично, и показывает последний промежуточный вывод. Ответ, упёршийся в предел длины, получает отметку: человек видит надпись «Ответ оборван по длине» (`labels.answerCutByLength`, в скине — `AgentMessage.cutByLength`), модель в следующих ходах видит пометку в конце своего ответа. Ответ из одних рассуждений (например, «Стоп» на рассуждении) больше не идёт в запрос, а сообщения человека, оказавшиеся подряд, склеиваются в одно (строгие провайдеры не принимают два подряд). Сегодняшняя дата словами идёт в конце последнего сообщения человека, только в запросе — начало запроса не меняется, кэш цел. Дата берётся по часовому поясу человека: браузер шлёт свой пояс (`timeZone`) с каждым запросом хода, сервер берёт только настоящий пояс IANA, иначе — новая настройка `agent.timeZone`, без неё — пояс сервера. Ни один ответ больше не бесплатен в журнале расхода: шаг, оборванный «Стопом» или сбоем после первых слов, и шаг, по которому провайдер не прислал числа, прикидываются по длине текста с учётом письма (кириллица, JSON и иероглифы плотнее латиницы; прикидка скорее завышает), а ответ, оборванный перезапуском сервера, записывается при следующем запуске (`recoverInterruptedAnswers`): только то, чего ещё нет в журнале, поэтому сбой после «Разрешить» не задваивает расход. Такие записи и итог ответа помечены «примерно» (`estimated`). В SQLite и PostgreSQL у записей расхода новая колонка `estimated`: миграция применяется сама при открытии хранилища. Своим хранилищам менять ничего не нужно: без колонки теряется только пометка. Фильтр `sumUsage` получил поля `messageId` и `source`: свои хранилища должны их учитывать (общие тесты `describeChatStorageContract` это проверяют).
1164
+ - 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 минут. Молчание посреди ответа идёт в автопродолжение. Время работы инструмента молчанием модели не считается. После срабатывания любого предела место хода освобождается, «Стоп» работает как раньше, запрос к модели не меняется — кэш цел. Журнал сервера: `[инструмент не ответил] чат=… инструмент=… срок=…мс` и `[молчание модели] … ждали=первый-кусок|следующий-кусок срок=…мс`. Приложению ничего менять не нужно; неверное значение настройки — ошибка при создании службы.
1165
+ - 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 }`, — добавьте эти ключи.
1166
+ - 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 знаков, проходит).
1167
+ - 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`.
1168
+
1169
+ **Изменение договора надписей:** в `ChatLabels` два новых обязательных ключа — `errors.forbidden_origin` и `editErrors.forbiddenOrigin`. Словарь на основе `{ ...russianLabels }` получает их сам; свой словарь целиком — дополните ими, иначе ошибка типов. Отдельного номера версии у договора надписей нет. Ещё: при отказе по месту хода (`too_many_turns`) или занятом чате файлы вложений больше не принимаются и не остаются ничьими; пока файлы принимаются, чат занят, и второе сообщение в него получает 409.
1170
+ - 0e8e0ae: Голосовой режим: человек продолжил говорить, пока модель ещё думает, — вторая фраза дописывается к первой, а не уходит вторым сообщением. Ход останавливается, вместо прежнего сообщения уходит склеенное («Так. Дай-ка подумать.»), модель отвечает один раз; пустого оборванного ответа и отдельного «Так.» в чате нет.
1171
+
1172
+ - Склеивается, пока у хода нет ни текста, ни звука, ни начатого вызова инструмента (размышление и заполнитель не мешают). Текст или звук уже пошли, начат инструмент, сказано стоп-слово или два междометия — поведение прежнее: перебивание и отдельное сообщение. Подробности — в README пакета voice-dialog, раздел «Склейка фраз».
1173
+ - Склейка идёт через правку последнего сообщения человека: старая фраза остаётся в базе, но обрывком склейки: переключателя веток у склеенного сообщения нет, пролистать на обрывок нельзя (если человек потом сам правит склеенное сообщение, появляется настоящая развилка без обрывков). Признак — часть `data-voice-mode` склеенного сообщения (`{ replaces: id }`), без миграции базы. Отметка голосового режима и подсказки режима сохраняются, кэш модели цел. Название чата, выведенное из заменённого сообщения, обновляется.
1174
+ - `core`: `chatService.editMessage(userId, chatId, editedMessageId, message, options?)` принимает `{ voiceMode: true }`. `server`: `POST …/edit` принимает необязательное булево `voiceMode` (не булево — 400 `invalid`).
1175
+ - `react`: у состояния чата появился `replaceLastVoiceMessage(text)`, у моста `DialogChatBridge` — необязательный `replaceLastMessage(text)`; `useVoiceDialog` его реализует.
1176
+
1177
+ Обновление ничего не требует: движок склеивает сам, если мост и сервер той же версии. Приложение со своим мостом чата без `replaceLastMessage` работает как раньше: новая фраза в ожидании ответа перебивает ход и уходит отдельным сообщением.
1178
+ - 6b20d83: Голосовой режим («разговор»): человек говорит, слова сами уходят сообщением в чат, ответ агента звучит голосом, человека можно перебить. Новый пакет `@ykurilov/agent-chat-voice-dialog`: распознавание Soniox, озвучка ElevenLabs (голос по умолчанию «Профессиональный диктор для аудиокниг», модель `eleven_v4_turbo`), ключи только на сервере. Перебитый ответ остаётся в ленте с отметкой «Вы перебили: озвучено до «…»», она хранится и видна после перезагрузки. Расход на озвучку идёт в учёт и лимиты (`source: "speech"`). Человек выбирает голос в меню режима; выбор хранится на сервере.
1179
+
1180
+ Модель узнаёт о режиме двумя служебными пометками: одна при входе («ответы озвучиваются, отвечай коротко и разговорно»), одна при возврате к тексту («разговор снова текстом»). Они стоят в конце сообщения человека, где режим сменился, а не в каждом запросе. Сообщение из режима запоминается голосовым отметкой в самом сообщении (часть `data-voice-mode` в `parts`), поэтому миграции для этого нет, пометки выводятся из сохранённого и кэш модели цел. Тексты задают настройки агента `voiceModeHint` и `textModeNote` (есть умолчания).
1181
+
1182
+ Обновление ничего не ломает: без нового пакета чат работает как раньше. Диктовка в поле ввода (`@ykurilov/agent-chat-voice`) не меняется.
1183
+
1184
+ ### Как обновить приложение
1185
+
1186
+ Нужно, только если вы хотите голосовой режим.
1187
+
1188
+ 1. Поставьте пакет `@ykurilov/agent-chat-voice-dialog` той же версии, что и остальные пакеты SDK. Скин Тандем обновите вместе с SDK: договор скина теперь версии 10 (необязательный `dialog` у поля ввода, панель режима, меню голосов, отметка обрыва; свой скин может их не рисовать, тогда кнопки режима не будет).
1189
+ 2. Сервер. Рядом с чатом подключите режим (нужен WebSocket; с Hono и `@hono/node-ws`):
1190
+
1191
+ ```ts
1192
+ import { mountDialogRelay } from "@ykurilov/agent-chat-voice-dialog/hono";
1193
+
1194
+ mountDialogRelay(app, {
1195
+ path: "/api/voice-dialog",
1196
+ upgradeWebSocket,
1197
+ storage,
1198
+ resolveUser, // тот же вход, что у чата
1199
+ spendingLimits, // по желанию
1200
+ logError: (message) => console.error(message),
1201
+ speech: {
1202
+ voices: [{ id: "oE0n0S3wK5Mj9C62iehn", name: "Профессиональный диктор для аудиокниг" }],
1203
+ defaultVoiceId: "oE0n0S3wK5Mj9C62iehn",
1204
+ pricePerThousandChars: { eleven_v4_turbo: 0.11, eleven_v4: 0.22 }, // ваша цена, $ за 1000 знаков
1205
+ },
1206
+ });
1207
+ ```
1208
+
1209
+ Ключи `ELEVENLABS_API_KEY` и `SONIOX_API_KEY` — в окружении сервера. Next.js: WebSocket нужен свой Node-сервер, см. README пакета (`createDialogRelay`).
1210
+ 3. Браузер:
1211
+
1212
+ ```tsx
1213
+ const voiceDialog = createVoiceDialogEngine({ basePath: "/api/voice-dialog" });
1214
+ <AgentChat api="/api/chat" skin={tandemSkin} voiceDialog={voiceDialog} />
1215
+ ```
1216
+
1217
+ 4. Настройки. Слова остановки и междометия по умолчанию только русские; для другого языка задайте `bargeIn.stopWords` и `bargeIn.ignoreWords` у движка и `language` на сервере. Строгая политика содержимого страницы: добавьте `worklet-src blob:`.
1218
+ 5. Хранилище. SQLite получает миграцию 0008, PostgreSQL — 0007 (схема `agent_chat`) сами при открытии базы. Свой `ChatStorage`: добавьте необязательные методы `setVoiceCut`, `getVoiceChoice`, `setVoiceChoice` (без них отметка обрыва и выбор голоса живут только до перезагрузки) и значение `"speech"` в `UsageSource`, если учёт расхода у вас свой.
1219
+ 6. Свой экран (не наш скин): части сообщения с типом `data-voice-mode` не рисуйте, показывайте только текстовые части.
1220
+ 7. Проверка: `pnpm typecheck` проходит; в чате появилась кнопка режима, после нажатия виден статус «Слушаю», фраза уходит сообщением, ответ звучит.
1221
+ - d12dcf3: Голосовой режим: звуки человека (смех, вздох, кашель…) доходят до модели.
1222
+
1223
+ - Звук с микрофона параллельно с 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 с): не успел — звуки приклеятся к следующей фразе. Куски не копятся в очередь (не больше двух запросов). Расход считается по фактически отправленным секундам, включая перекрытия. Фраза из одного смеха без слов ход модели не запускает.
1224
+ - Звуки лежат в сообщении человека, в части `data-voice-mode` (`data: { sounds: ["laughter"] }`), уходят на сервер как `voiceSounds` вместе с `voiceMode` (значение не из закрытого списка — 400 `invalid`). Пометка для модели («были слышны его звуки: смех») выводится из сохранённого при сборке запроса: начало запроса от хода к ходу не меняется, кэш цел; склейка фраз сохраняет звуки обеих. На экране звуков нет. В пометке входа в режим появилось короткое правило про пометку звуков.
1225
+ - Эхо: звук того же вида, пойманный, пока играл кусок озвучки агента с таким же звуком (тег «[laughs]», заполнитель, отдельное «Ха-ха!»), плюс короткий хвост на задержку микрофона (`soundEchoTailMs`, 0,6 с), звуком человека не считается; смех в ответ на шутку агента доходит.
1226
+ - Ошибка или недоступность Scribe режим не ломает: разговор идёт без звуков, в журнал — предупреждение. Расход пишется записью `source: "sounds"` (`inputTokens` — секунды звука) и входит в лимиты расходов.
1227
+ - Новое: `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`.
1228
+
1229
+ Как обновить приложение: ничего не требуется, пока `sounds` не задан. Чтобы включить, добавьте `sounds: { pricePerHour }` (цену Scribe берите из прайса ElevenLabs; v2 — 0,22 $ за час) в настройки режима; ключ тот же `ELEVENLABS_API_KEY`. Своё хранилище расходов должно принимать `source: "sounds"` (текстовое поле, миграций не нужно). Пометка входа в режим по умолчанию стала длиннее на одно правило: кэш модели у идущих голосовых чатов один раз промахнётся. Свой `agent.voiceModeHint` (строка) заменяет пометку входа целиком, правило про звуки в нём не появится, но пометка звуков у сообщений всё равно добавится.
1230
+
1231
+ ### Patch Changes
1232
+
1233
+ - 96098be: Мелкие исправления (022). Символ с кодом ноль в первом сообщении больше не роняет отправку на Postgres: он выбрасывается из названия чата, из записей памяти и из сводки сжатия истории (иначе запись сжатия падала бы при каждой попытке); в самом тексте сообщения он лежит как раньше. Вложения отмечаются прикреплёнными до записи сообщения, а не после: если сервер упал между двумя шагами, чистка брошенных файлов не удалит их через сутки (файл, отмеченный зря, безвреден и уходит вместе с чатом). Во всех пакетах появилось поле `engines: { node: ">=22" }`; голосовой сервер (`createVoiceRelay`) и голосовой диалог (`createDialogRelay`) на Node 20 сразу при создании говорят понятной ошибкой, что нужен Node 22 (раньше падали при первом соединении; со своим `connectUpstream` проверки нет). В пакете вложений `react` и `@ykurilov/agent-chat-react` стали необязательными peer-зависимостями: серверу без экрана они больше не ставятся. Приложению, которое берёт `@ykurilov/agent-chat-attachments/react`, достаточно иметь их в проекте — для чата на экране они нужны и так; если они подтягивались только через вложения, добавьте их в зависимости приложения.
1234
+ - 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`).
1235
+ - Updated dependencies [4f33e85]
1236
+ - Updated dependencies [15fbbcc]
1237
+ - Updated dependencies [c09c1e2]
1238
+ - Updated dependencies [062ce36]
1239
+ - Updated dependencies [656233a]
1240
+ - Updated dependencies [96098be]
1241
+ - Updated dependencies [b6f4855]
1242
+ - Updated dependencies [278a718]
1243
+ - Updated dependencies [c1581f4]
1244
+ - Updated dependencies [213bcb3]
1245
+ - Updated dependencies [927c02c]
1246
+ - Updated dependencies [be77ee2]
1247
+ - Updated dependencies [8b59096]
1248
+ - Updated dependencies [7fbc105]
1249
+ - Updated dependencies [84ad64f]
1250
+ - Updated dependencies [5027963]
1251
+ - Updated dependencies [c2769dd]
1252
+ - Updated dependencies [0cdf102]
1253
+ - Updated dependencies [ba16989]
1254
+ - Updated dependencies [aba59fe]
1255
+ - Updated dependencies [47a56ac]
1256
+ - Updated dependencies [f9b6162]
1257
+ - Updated dependencies [f9b6162]
1258
+ - Updated dependencies [f9b6162]
1259
+ - Updated dependencies [1b2f9f1]
1260
+ - Updated dependencies [0e8e0ae]
1261
+ - Updated dependencies [6b20d83]
1262
+ - Updated dependencies [d12dcf3]
1263
+ - Updated dependencies [366d785]
1264
+ - Updated dependencies [946e5d8]
1265
+ - @ykurilov/agent-chat-core@0.14.0
1266
+
1267
+ ## 0.13.0
1268
+
1269
+ ### Patch Changes
1270
+
1271
+ - Updated dependencies [4793e4d]
1272
+ - @ykurilov/agent-chat-core@0.13.0
1273
+
1274
+ ## 0.12.0
1275
+
1276
+ ### Patch Changes
1277
+
1278
+ - @ykurilov/agent-chat-core@0.12.0
1279
+
1280
+ ## 0.11.0
1281
+
1282
+ ### Patch Changes
1283
+
1284
+ - @ykurilov/agent-chat-core@0.11.0
1285
+
1286
+ ## 0.10.0
1287
+
1288
+ ### Minor Changes
1289
+
1290
+ - 4a76ff3: **Ломает приложения:** у `ChatStorage` появились обязательные операции `updateAwaitingMessage` и `getMessage`, у словаря `ChatLabels` — новые ключи `approval*`; касается приложений со своим хранилищем и с полным словарём надписей.
1291
+
1292
+ Подтверждение перед опасными действиями агента. Инструмент, помеченный `needsApproval: true` (или функцией от входных данных), не выполняется без разрешения человека: ход останавливается, в ленте появляется карточка с названием действия, входными данными и кнопками «Разрешить», «Отклонить» и «Всегда в этом чате». Кнопка «Всегда» есть только у инструмента с `needsApproval: true`; у инструмента с функцией её нет, каждый вызов спрашивает заново. Ждать можно сколько угодно: состояние лежит на сервере, переживает перезагрузку страницы и перезапуск сервера, ответить можно с любого устройства.
1293
+
1294
+ Что изменилось:
1295
+
1296
+ - `@ykurilov/agent-chat-core`: статус ответа `awaiting_approval`; `answerApproval` у сервиса чата; ответ после решения дописывается в то же сообщение, инструмент выполняется ровно раз. «Отклонить» — модель получает «пользователь отклонил» и продолжает. Новое сообщение вместо ответа на карточку считается отказом. Сбой или «Стоп» после «Разрешить» не оставляют вызов без итога: он закрывается как прервавшийся («результат неизвестен», если инструмент уже мог сработать). Ответ на карточку проверяет лимит расходов (429), а расход паузы и продолжения не считается дважды. После смены модели во время ожидания продолжение идёт на модели ждущего сообщения. Кэш модели не страдает: инструкция и набор инструментов не меняются.
1297
+ - `@ykurilov/agent-chat-server`: адрес `POST /:chatId/approvals/:approvalId` с телом `{ "decision": "allow" | "deny" | "always" }`. Ошибки: 409 `busy` (в чате идёт другой ход), 409 `approval_not_pending` (на этот запрос отвечать уже нечем), 429 `spending_limit_reached`.
1298
+ - `@ykurilov/agent-chat-storage-sqlite`, `@ykurilov/agent-chat-storage-postgres`: реализуют `updateAwaitingMessage` и `getMessage`. Схема базы не меняется, миграции не нужны: статус ответа хранится обычным текстом.
1299
+ - `@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`.
1300
+ - `@ykurilov/agent-chat-skin-tandem`: карточка подтверждения (группа с названием для читалки, «Ждёт вашего решения» и итог озвучиваются, фокус не теряется, на телефоне длинные поля не выпирают) и пометка решения в строке вызова.
1301
+
1302
+ ### Как обновить приложение
1303
+
1304
+ Если опасных инструментов нет, а хранилище и скин стандартные, обновление ничего не требует.
1305
+
1306
+ 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` проходит.
1307
+ 2. Полный свой словарь надписей (`ChatLabels`, объект без `...russianLabels`): добавьте двенадцать ключей `approval*` из списка выше. Словарь `{ ...russianLabels, … }` менять не нужно.
1308
+ 3. Свой скин: если `ToolCallRow` свой, добавьте разбор `approval` — `{ state: "requested", onDecide, pending, failed, failureText?, canAlways }` (нарисовать кнопки, `onDecide("allow" | "deny" | "always")`; «Всегда» показывать только при `canAlways`; при `failed` показать `failureText`, если он есть) и `{ state: "allowed" | "always" | "denied" }` (итог решения). Нет `approval` — рисуйте строку как раньше. Название действия — в `title`.
1309
+ 4. Чтобы включить подтверждение: у опасного инструмента задайте `needsApproval: true` (можно функцией от входных данных; с функцией кнопки «Всегда» не будет) и `title` — название для карточки. Больше ничего подключать не надо.
1310
+ 5. Проверка: `pnpm typecheck`; попросите агента вызвать опасный инструмент — в ленте карточка, после «Разрешить» действие выполнено, ответ дописан в то же сообщение.
1311
+
1312
+ ### Patch Changes
1313
+
1314
+ - Updated dependencies [4a76ff3]
1315
+ - @ykurilov/agent-chat-core@0.10.0
1316
+
1317
+ ## 0.9.0
1318
+
1319
+ ### Minor Changes
1320
+
1321
+ - d07a1c3: **Ломает приложения:** только если вы сами реализовали `ChatStorage` или собрали словарь надписей целиком (без `{ ...russianLabels, … }`); остальным ничего делать не нужно.
1322
+
1323
+ Новый пакет `@ykurilov/agent-chat-limits`: лимиты расходов. Бюджет в долларах на человека, на чат или на то и другое; период — календарный месяц (по умолчанию), сутки или без обнуления. Исчерпанный лимит останавливает ход до его начала; при 80% чат предупреждает.
1324
+
1325
+ Что изменилось:
1326
+
1327
+ - Ядро: цены в профиле модели (`ModelProfile.pricing` по провайдерам; вход, выход, чтение и запись кэша, рассуждения считаются как выход). Цены есть у всех стандартных моделей. Журнал расхода `usage_records` — токены и стоимость в долларах (`costUsd`, по ценам модели на момент хода) каждого ответа и фонового сжатия, в том числе сжатия, которое не удалось, но было оплачено; запись датируется началом хода. Журнал только дописывается и переживает удаление чата. Сумма лимита берётся из записей, поэтому смена цен и удаление модели из каталога прошлый расход не меняют. Новые методы хранилища `insertUsageRecord` и `sumUsage`.
1328
+ - Ядро: `createChatService({ limits })` и `ChatService.getSpendingStatus`. Проверка идёт перед отправкой, повтором, переделкой и правкой, до любых записей; исчерпание — `SpendingLimitReachedError`.
1329
+ - Сервер: `createChatHandler({ limits })`. Исчерпанный лимит — `429 {"error":{"kind":"spending_limit_reached","scope","period","resetsAt"}}`; новый адрес `GET /api/chat/:id/spending` (без сумм в долларах).
1330
+ - `@ykurilov/agent-chat-limits`: `createSpendingLimits({ storage, catalog, budget, period?, warnAt?, unpricedModels?, now?, logError? })` и `getSpending({ userId, chatId })` для страницы «тариф» (расход чата считается только среди записей этого человека). Своя модель без цены — понятная ошибка при создании; явно `unpricedModels: "ignore"` — такие ходы пишутся с нулевой стоимостью. `compactNow` лимит не проверяет: ручное сжатие остаётся доступным.
1331
+ - Хранилища: SQLite (миграция `0006`) и PostgreSQL (миграция `0005`) добавляют таблицу `usage_records`; миграции применяются при открытии хранилища сами, существующие данные не трогаются. Проверено на копии базы песочницы.
1332
+ - Браузер: `AgentChat` принимает `spendingLimits`. Договор скина версии 8: необязательный компонент `SpendingNotice({ id, state, text })` (`state` бывает `none`: область для диктора всегда на странице) и необязательные свойства `Composer.sendingBlocked` и `Composer.sendingBlockedReasonId` (для `aria-describedby`). Скин Тандема их рисует; при остановке фокус переходит на надпись и возвращается в поле, когда лимит снят. Отказ 429 не запускает переподключения: текст возвращается в поле (при правке сообщения редактор остаётся открытым с текстом и надписью `editErrors.spendingLimit`), показывается «Лимит на месяц исчерпан, обновится 1 ноября»; исчерпание снимается само в момент обновления.
1333
+ - Словарь надписей: `spendingWarning` и `spendingExhausted` (функции от сведений о лимите) и `editErrors.spendingLimit`.
1334
+
1335
+ Не входит в расход: веб-инструменты (`web-tools`, другой сервис), незаконченный шаг остановленного хода и ход, оборванный перезапуском сервера. Ход, начатый в пределах бюджета, доводится до конца; одновременные ходы могут вместе выйти за бюджет (проверка не резервирует деньги).
1336
+
1337
+ ### Как обновить приложение
1338
+
1339
+ Если лимиты не нужны, делать ничего не нужно, кроме двух случаев:
1340
+
1341
+ 1. **Только если вы реализовали `ChatStorage` сами:** добавьте `insertUsageRecord(record)` и `sumUsage(filter)` (запись расхода несёт `costUsd`, сумма возвращает `costUsd`) и прогоните `describeChatStorageContract` (в нём появился блок «записи расхода»). Хранилища SQLite и PostgreSQL из этого репозитория обновляются сами.
1342
+ 2. **Только если словарь надписей написан целиком** (не через `{ ...russianLabels, … }`): добавьте функции `spendingWarning` и `spendingExhausted` и надпись `editErrors.spendingLimit`. Значения — в `russianLabels`.
1343
+
1344
+ Чтобы включить лимиты:
1345
+
1346
+ 1. `pnpm add @ykurilov/agent-chat-limits`.
1347
+ 2. На сервере: `const limits = createSpendingLimits({ storage, catalog, budget: async ({ userId }) => ({ user: 5 }) })` и `createChatHandler({ …, limits })`. `storage` и `catalog` — те же, что у чата.
1348
+ 3. В браузере: `<AgentChat … spendingLimits />`. Скин свой — добавьте `SpendingNotice` (с `id`, состоянием `none` и `role="status"`) и учтите `Composer.sendingBlocked` и `sendingBlockedReasonId`; нет их — надписи не будет, но отправка остановится.
1349
+ 4. Проверка: с маленьким бюджетом чат сначала предупреждает, потом останавливает отправку и пишет дату обновления.
1350
+
1351
+ ### Patch Changes
1352
+
1353
+ - Updated dependencies [d07a1c3]
1354
+ - @ykurilov/agent-chat-core@0.9.0
1355
+
1356
+ ## 0.8.0
1357
+
1358
+ ### Minor Changes
1359
+
1360
+ - a24c79d: **Ломает приложения:** у `ChatStorage` три новых обязательных метода (`listChats`, `renameChat`, `deleteChat`), у `AttachmentStorage` — один (`removeAllOfChat`), а у `ChatLabels` — 16 новых обязательных надписей `chatList*`. Приложения на готовых хранилищах и готовом словаре надписей ничего менять не должны; поломка только у своих реализаций.
1361
+
1362
+ Список чатов в боковой панели: создать чат, переименовать, удалить. Чат сам получает название по первому сообщению. Это расширение по желанию: приложение без `chatList` и без `ChatWorkspace` работает как раньше.
1363
+
1364
+ Что изменилось:
1365
+
1366
+ - `@ykurilov/agent-chat-core`: `createChatService` умеет `listChats(userId)`, `renameChat(userId, chatId, title)`, `deleteChat(userId, chatId)`. Название ставится по первому сообщению (до 60 знаков), вручную заданное не перезаписывается; длина названия до 100 знаков. Удаление настоящее: идущий ответ и сжатие останавливаются, затем убираются сообщения, записи сжатия, выбор веток, файлы вложений и сам чат. Чужой и несуществующий чат — одинаково `ChatNotFoundError`. Экспортируются `deriveChatTitle`, `normalizeChatTitle`, `InvalidChatTitleError`, тип `ChatListItem`. `Chat.title` необязательное. Набор проверок `describeChatStorageContract` расширен.
1367
+ - `@ykurilov/agent-chat-server`: `createChatHandler({ chatList: true })` включает три адреса: `GET {basePath}` (список), `PATCH {basePath}/:id` с телом `{ title }`, `DELETE {basePath}/:id`. Без `chatList` эти адреса отвечают 404. Чужой чат — 404, неверное название — 400. Идущий ответ удалению не мешает: он останавливается. `409` при удалении — только если чат уже удаляется или в этот миг запускается ход; пока чат удаляется, сообщение, сжатие и вложения в него тоже получают `409`. Переименование `409` не даёт.
1368
+ - `@ykurilov/agent-chat-storage-sqlite` и `@ykurilov/agent-chat-storage-postgres`: колонка `title` у чатов, методы `listChats`, `renameChat`, `deleteChat` (удаление в одной транзакции). Миграция сама заполняет названия старых чатов по их первому сообщению.
1369
+ - `@ykurilov/agent-chat-attachments`: `AttachmentStorage` получил `removeAllOfChat(chatId)`; `createAttachments` отдаёт ядру `removeChatFiles`, и файлы удаляются вместе с чатом. Набор `describeAttachmentStorageContract` расширен.
1370
+ - `@ykurilov/agent-chat-react`: компонент `ChatWorkspace` (список и открытый чат вместе), хук `useChatList`, у `AgentChat` необязательное `onBusyChange`. Договор скина версии 7: скин, который хочет работать с `ChatWorkspace`, дополнительно даёт `ChatWorkspace` и `ChatList` (тип `ChatListSkin`); договор самого чата не менялся. 16 новых надписей `chatList*` в словаре (`ChatLabels`).
1371
+ - `@ykurilov/agent-chat-skin-tandem`: `TandemChatWorkspace` и `TandemChatList`. На широком экране список слева, на узком — панель по кнопке «Чаты».
1372
+
1373
+ ### Как обновить приложение
1374
+
1375
+ Если список чатов не нужен, а хранилища и словарь надписей готовые, обновление ничего не требует.
1376
+
1377
+ Если у приложения своё `ChatStorage`:
1378
+
1379
+ 1. Добавьте методы `listChats(ownerId)`, `renameChat(chatId, title | null)` и `deleteChat(chatId)`. Список — только чаты владельца, самые недавно менявшиеся первыми; `deleteChat` убирает сообщения, записи сжатия, выбор веток и чат одной транзакцией. Точные правила — в общем наборе проверок.
1380
+ 2. Храните название чата (`Chat.title`, необязательное) и проведите свою миграцию.
1381
+ 3. Проверка: `describeChatStorageContract` из `@ykurilov/agent-chat-core/storage-contract` проходит.
1382
+
1383
+ Если у приложения своё `AttachmentStorage`: добавьте `removeAllOfChat(chatId): Promise<number>` (удалить все файлы чата, вернуть сколько) и прогоните `describeAttachmentStorageContract`.
1384
+
1385
+ Если у приложения свой полный словарь надписей (`ChatLabels` целиком, а не `{ ...russianLabels, … }`): добавьте 16 новых ключей, иначе `pnpm typecheck` упадёт. Простые строки: `chatListHeading` («Чаты»), `chatListNewChat` («Новый чат»), `chatListUntitled` («Новый чат»), `chatListLoading`, `chatListLoadFailed`, `chatListCreateFailed`, `chatListRenameFailed`, `chatListDeleteFailed`, `chatListRename`, `chatListRenameField`, `chatListDelete`, `chatListDeleteTitle`, `chatListDeleteConfirm`, `chatListDeleteCancel`. Функции от названия чата: `chatListRowMenu(title)` («Действия с чатом …») и `chatListDeleteBody(title)`.
1386
+ Было: `const labels: ChatLabels = { …прежние надписи }`. Стало: те же надписи плюс 16 новых; проще всего `{ ...russianLabels, …свои }`.
1387
+
1388
+ Чтобы включить список чатов:
1389
+
1390
+ 1. Сервер: `createChatHandler({ …, chatList: true })`. Базы SQLite и PostgreSQL обновятся сами при открытии.
1391
+ 2. Браузер: вместо `<AgentChat api chatId … />` — `<ChatWorkspace api="/api/chat" skin={tandemSkin} … />` (те же остальные свойства, кроме `chatId`). Открытый чат приложение может показывать в адресе: `initialChatId` и `onChatChange`.
1392
+ 3. Если скин свой: добавьте `ChatWorkspace` и `ChatList` по договору версии 7 (`ChatListSkin`). Скин Тандема обновляется вместе с пакетом.
1393
+ 4. Если приложение само создавало чат кнопкой «Новый чат», уберите её: «Новый чат» теперь в списке.
1394
+ 5. Проверка: `pnpm typecheck` (ловит недостающие методы хранилищ и надписи); в списке видны чаты пользователя, «Новый чат» открывает пустой чат, после первого сообщения чат получает название, переименование и удаление (с подтверждением) работают.
1395
+
1396
+ ### Patch Changes
1397
+
1398
+ - Updated dependencies [a24c79d]
1399
+ - @ykurilov/agent-chat-core@0.8.0
1400
+
1401
+ ## 0.7.0
1402
+
1403
+ ### Minor Changes
1404
+
1405
+ - b8f9e9f: **Ломает приложения со своими хранилищами и своими скинами:** ответ агента можно переделать, а отправленное сообщение исправить; все варианты сохраняются, между ними переключает «‹ 2/3 ›».
1406
+
1407
+ Что изменилось:
1408
+
1409
+ - Под ответом агента появилась кнопка «Переделать ответ»: агент отвечает на тот же вопрос заново, прежний ответ остаётся вариантом. Под сообщением собеседника появился карандаш «Изменить сообщение»: правка идёт на месте пузыря, отправляется новой веткой с новым ответом агента.
1410
+ - Когда у сообщения больше одного варианта, рядом стоит переключатель «‹ 2/3 ›». Выбранный вариант запоминается на сервере: после обновления страницы или на другом устройстве открывается он же.
1411
+ - Ничего не удаляется и не переписывается: журнал переписки только дописывается, выбор варианта — отдельная запись в новой таблице `branch_selections`.
1412
+ - Модель видит только текущую ветку: после правки она не знает прежнего текста, после переключения на прежний вариант — знает. Инструкция и инструменты в запросе не меняются, кэш модели ветки не задевают. Сжатие истории и поиск по старой переписке работают по текущей ветке (сводка действует на ветке, где она сделана).
1413
+ - Правка сообщения с файлами (пакет вложений): файлы остаются как были и переходят в новый вариант, меняется только текст; убрать или добавить файл при правке нельзя. Сообщение из одних файлов можно отправить и с пустым текстом. Файлы проходят ту же проверку сервера, что и при обычной отправке.
1414
+ - Пока идёт ответ, переключать, переделывать и править нельзя: сервер отвечает 409 `busy`, кнопки в экране неактивны. Если отказ всё же пришёл (ответ пошёл с другой вкладки), поле правки остаётся открытым с текстом собеседника и понятной ошибкой: текст не пропадает.
1415
+ - Новые адреса в `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>`: правка началась, не началась или сервер отказал).
1416
+ - Базы SQLite (миграция 0004) и PostgreSQL (миграция 0003) получают новую таблицу сами при первом запуске. Ничего запускать вручную не нужно.
1417
+ - В скине Тандема строка с карандашом и переключателем занимает прежний промежуток между сообщениями: высота сообщений не меняется.
1418
+
1419
+ ### Как обновить приложение
1420
+
1421
+ 1. Обновите все пакеты SDK одной командой: `pnpm update "@ykurilov/agent-chat-*" --latest`. Базы обновятся сами при первом запуске сервера.
1422
+ 2. **Если у приложения своё хранилище** (свой класс, реализующий `ChatStorage`): добавьте два метода и таблицу под них.
1423
+
1424
+ ```ts
1425
+ insertBranchSelection(record: StoredBranchSelection): Promise<void>; // дописать запись «выбран этот вариант»
1426
+ listBranchSelections(chatId: string): Promise<StoredBranchSelection[]>; // записи чата по порядку добавления
1427
+ ```
1428
+
1429
+ `StoredBranchSelection` — `{ id, chatId, messageId, selectedAt }` (оба времени — строка ISO). Записи только добавляются, не меняются и не удаляются. Тип и общий тест `describeChatStorageContract` берутся из `@ykurilov/agent-chat-core`; тест теперь проверяет и эти методы. Приложения на памяти, SQLite и PostgreSQL из наших пакетов ничего не меняют.
1430
+ 3. **Если у приложения свой скин** (объект `ChatSkin` не из `@ykurilov/agent-chat-skin-tandem`): договор скина теперь версии 6 (версия 5 — голосовой ввод и вложения, необязательные `Composer.voice`, `Composer.attachments`, `UserMessage.attachments`). Добавьте два компонента и новые свойства сообщений.
1431
+
1432
+ ```tsx
1433
+ const BranchSwitcher: ChatSkin["BranchSwitcher"] = ({ position, count, onPrevious, onNext }) => {
1434
+ const labels = useChatLabels();
1435
+ return (
1436
+ <div role="group" aria-label={labels.branchPosition(position, count)}>
1437
+ <button type="button" aria-label={labels.previousBranch} disabled={!onPrevious} onClick={onPrevious}>‹</button>
1438
+ {position}/{count}
1439
+ <button type="button" aria-label={labels.nextBranch} disabled={!onNext} onClick={onNext}>›</button>
1440
+ </div>
1441
+ );
1442
+ };
1443
+ const MessageEditor: ChatSkin["MessageEditor"] = ({ value, onValueChange, onSave, onCancel, canSave, error, attachments }) => {
1444
+ const labels = useChatLabels();
1445
+ return (
1446
+ <div>
1447
+ {attachments && <ul aria-label={attachments.label}>{attachments.items.map((item) => <li key={item.id}>{item.name}</li>)}</ul>}
1448
+ <textarea aria-label={labels.editMessage} value={value} onChange={(event) => onValueChange(event.target.value)} />
1449
+ {error && <p role="alert">{error}</p>}
1450
+ <button type="button" onClick={onCancel}>{labels.cancelEdit}</button>
1451
+ <button type="button" disabled={!canSave} onClick={onSave}>{labels.saveEdit}</button>
1452
+ </div>
1453
+ );
1454
+ };
1455
+ const skin = { ...mySkin, BranchSwitcher, MessageEditor };
1456
+ ```
1457
+
1458
+ `UserMessage` получает свойства `branchSwitcher?: ReactNode` (положить рядом с сообщением) и `onEdit?: () => void` (кнопка «Изменить сообщение»; когда правка недоступна, свойства нет). `AgentMessage` получает `branchSwitcher?: ReactNode` и `onRegenerate?: () => void` (кнопка «Переделать ответ», только у последнего законченного ответа). Свойства необязательны: скин без них не ломается, только не показывает кнопок. Пример — `skins/tandem/src` в репозитории SDK.
1459
+ 4. **Если словарь надписей написан целиком**, а не через `{ ...russianLabels, … }`: добавьте ключи `previousBranch` («Предыдущий вариант»), `nextBranch` («Следующий вариант»), `branchPosition(position, count)` («Вариант 2 из 3»), `regenerate` («Переделать ответ»), `editMessage` («Изменить сообщение»), `saveEdit` («Отправить»), `cancelEdit` («Отмена»), `editErrors` — `{ busy: "Ответ ещё идёт. Дождитесь его конца и отправьте снова", offline: "Нет связи с сервером. Текст сохранён в поле", rejected: "Сервер не принял правку. Текст сохранён в поле" }`.
1460
+ 5. **Если приложение само собирает ленту через `useAgentChat`**: подключите `forks` и `selectBranch`, как в `packages/react/src/agent-chat.tsx`. Готовый `AgentChat` ничего менять не просит.
1461
+ 6. Проверка: `pnpm typecheck` проходит; отправьте сообщение, нажмите «Переделать ответ» — под ответом появится «2/2», стрелка «‹» вернёт первый вариант, после обновления страницы выбор сохранится. Подробности — раздел «Ветки ответов и правка сообщений» в `docs/getting-started.md`.
1462
+ - 7c205c4: Новый пакет `@ykurilov/agent-chat-attachments`: вложения к сообщению. В поле ввода появляются скрепка, перетаскивание и вставка файлов из буфера, превью с удалением; в ленте у сообщения — миниатюры картинок и плашки файлов. Картинки, PDF и текстовые файлы; тип определяется по содержимому.
1463
+
1464
+ Что изменилось:
1465
+
1466
+ - `@ykurilov/agent-chat-attachments`: `createAttachments({ storage, limits? })`, хранилища файлов `createDiskAttachmentStorage({ directory })` и `createMemoryAttachmentStorage()`, `attachmentsInstructions`, общий набор проверок своего хранилища `describeAttachmentStorageContract` (подпуть `/storage-contract`). Подпуть `/react`: `attachmentsClient`. Пределы по умолчанию: 10 файлов, 20 МБ на файл, потолок извлечённого текста; меняются в `limits`.
1467
+ - Картинки уходят только моделям с `acceptsImages`; если модель не видит, человек узнаёт об этом до отправки. Текст PDF и текстовых файлов достаётся на сервере и идёт любой модели. Кэш модели не страдает: извлечённый текст не меняется от хода к ходу.
1468
+ - `@ykurilov/agent-chat-core`: `createChatHandler`/`createChatService` принимают необязательное `attachments`; меню моделей несёт `acceptsImages`; задание сжатия просит назвать прикреплённые файлы в сводке (оригиналы остаются). Без `attachments` файлы в сообщении отклоняются.
1469
+ - `@ykurilov/agent-chat-server`: новые адреса под адресом чата: `GET /attachments/settings`, `POST /:chatId/attachments`, `GET` и `DELETE /:chatId/attachments/:id`.
1470
+ - `@ykurilov/agent-chat-react`: `AgentChat` принимает необязательное `attachments`. Договор скина версии 5: `Composer` и `UserMessage` получают необязательное `attachments`, у моделей в `ModelChoiceView` появилось необязательное `acceptsImages`.
1471
+ - `@ykurilov/agent-chat-skin-tandem`: скрепка, превью, миниатюры и плашки файлов. Встроенный микрофон полного поля Тандема спрятан: голос — отдельная кнопка (пакет `voice`).
1472
+ - Разбор PDF идёт в отдельном потоке с потолком памяти и сроком: зависший или «раздутый» файл обрывается, сервер живёт дальше; текст собирается до потолка символов.
1473
+ - Лимиты на человека: `maxUnsentFiles` (30), `maxUnsentBytes` (200 МБ), `maxParallelUploads` (8), `maxParallelUploadsPerUser` (3); отказы 429 `too_many_unsent` и `busy`. Файлы с ошибкой в лимит не входят. Тело загрузки читается потоком без лишних копий. Поле ввода грузит файлы по два одновременно, остальные ждут в очереди, а на отказ `busy` повторяет несколько раз.
1474
+ - Диск чистит недописанные остатки (`.tmp` и `.bin` без описания) по возрасту; неотправленные файлы убираются через 24 часа, прикреплённые не трогаются. Просроченный файл возвращается в поле с пометкой «загрузите снова».
1475
+ - Файл помечается «прикреплён» только после записи сообщения (`confirmUserParts` в контракте `MessageAttachments`; `acceptUserParts` файлы не помечает). Отказ отправки возвращает текст и файлы в поле, повторный клик не очищает его раньше времени. Контракт `AttachmentsController` в `@ykurilov/agent-chat-react`: вместо `takeFiles` — `readFiles`, `markSent`, `restoreFiles`; `useAgentChat.submit` возвращает признак «принято».
1476
+ - Текстовые логи с цветовыми кодами терминала (ANSI) принимаются как текст, коды убираются. Закрывающий тег `</attachment>` в тексте файла обезвреживается без учёта регистра.
1477
+ - `MessageAttachments.instructions`: ядро само дописывает инструкцию про вложения к инструкции агента (один раз, дубль не появляется). Вручную `attachmentsInstructions` добавлять больше не нужно, но и не вредно.
1478
+ - `@ykurilov/agent-chat-skin-tandem`: пока грузится список моделей, в поле со вложениями нет пустой кнопки «Выберите модель».
1479
+ - Схема базы чата не меняется, миграции не нужны.
1480
+
1481
+ ### Как обновить приложение
1482
+
1483
+ Если вложения не нужны, обновление ничего не требует: без `attachments` чат работает как раньше.
1484
+
1485
+ Чтобы включить вложения:
1486
+
1487
+ 1. `pnpm add @ykurilov/agent-chat-attachments`.
1488
+ 2. На сервере: `const attachments = createAttachments({ storage: createDiskAttachmentStorage({ directory: "./data/attachments" }) })`, передайте `attachments` в `createChatHandler` и инструкцию агенту про вложения ядро добавляет само. Папку с файлами включите в резервные копии вместе с базой.
1489
+ 3. В браузере: `<AgentChat … attachments={attachmentsClient} />` (`attachmentsClient` из `@ykurilov/agent-chat-attachments/react`).
1490
+ 4. Если скин свой: `Composer` получает `attachments` (скрепка, превью, `notice`), `UserMessage` — `attachments` (миниатюры и плашки). Нет свойства — ничего про вложения не рисовать. Свой скин на `@ykurilov/agent-chat-skin-tandem` обновляется вместе с пакетом.
1491
+ 5. Проверка: `pnpm typecheck`; в поле ввода есть скрепка, картинка и PDF прикрепляются, в ленте видны миниатюра и плашка, агент отвечает по содержимому.
1492
+ - e741e6d: Новый пакет `@ykurilov/agent-chat-voice`: голосовой ввод через Soniox. В поле ввода появляется кнопка микрофона, слова видны по ходу речи, человек правит и отправляет сам. Главный ключ Soniox остаётся на сервере, браузеру выдаётся временный.
1493
+
1494
+ Что изменилось:
1495
+
1496
+ - `@ykurilov/agent-chat-voice/server`: `createVoiceHandler({ basePath, resolveUser })` — адрес `POST /api/voice/session`, ключ из `SONIOX_API_KEY`. Адреса Soniox настраиваются (`apiBaseUrl`, `apiHeaders`, `websocketUrl`) — для сервера в России.
1497
+ - `@ykurilov/agent-chat-voice`: `createVoiceInput({ api })` — микрофон и соединение с Soniox в браузере. Формат записи выбирается явно (webm/opus → ogg/opus → mp4/AAC).
1498
+ - Режим «звук через наш сервер»: `createVoiceInput({ api, mode: "relay" })` в браузере, `createVoiceRelay` в `…/voice/server` и `mountVoiceRelay` в новом подпути `…/voice/hono` (Hono на Node через `@hono/node-ws`). Нужен там, где Soniox недоступен из браузера; адрес и заголовки прокси настраиваются (`websocketUrl`, `upstreamHeaders`). Next.js WebSocket в route handlers не держит — нужен отдельный Node-сервер.
1499
+ - Ограничение частоты: `rateLimit` (по умолчанию 10 диктовок в минуту на человека, в памяти процесса; общие лимиты — на приложении). Ответ с ключом идёт с `Cache-Control: no-store`; исключение из `resolveUser` даёт 503 и запись в `logError`.
1500
+ - Предел диктовки 5 минут: запись останавливается за две секунды до него, человек видит причину.
1501
+ - Отправка сообщения во время диктовки останавливает диктовку.
1502
+ - Ручная правка посреди слова, которое Soniox ещё не закрепил, больше не портит текст: сказанное учитывается только по закреплённой части.
1503
+ - `AgentChat` принимает необязательное свойство `voice`; без него кнопки нет. Договор скина версии 5: `Composer` получает необязательное `voice` (`VoiceInputView`). Скин Тандема рисует кнопку сам (`TandemVoiceButton`).
1504
+ - В словаре надписей новые ключи: `voiceStart`, `voiceStop`, `voiceConnecting`, `voiceErrors` (виды ошибок, в том числе `rate_limited` и `time_limit`).
1505
+ - `mountChat` и `createNextRouteHandlers` теперь принимают любой объект с `basePath` и `fetch`, а не только обработчик чата — так подключается и голос.
1506
+
1507
+ ### Как обновить приложение
1508
+
1509
+ Если голос не нужен, ничего делать не нужно, кроме одного случая:
1510
+
1511
+ 1. **Только если словарь надписей написан целиком** (не через `{ ...russianLabels, … }`): добавьте ключи `voiceStart`, `voiceStop`, `voiceConnecting` и `voiceErrors` (по видам `permission_denied`, `no_microphone`, `unsupported`, `network`, `service_unavailable`, `rate_limited`, `time_limit`, `other`). Значения — в `russianLabels` из `@ykurilov/agent-chat-react`.
1512
+
1513
+ Чтобы включить голос:
1514
+
1515
+ 1. `pnpm add @ykurilov/agent-chat-voice`, ключ Soniox положите в окружение сервера как `SONIOX_API_KEY`.
1516
+ 2. На сервере рядом с чатом: `mountChat(app, createVoiceHandler({ basePath: "/api/voice", resolveUser }))` (в Next.js — второй файл маршрута `app/api/voice/[[...path]]/route.ts`).
1517
+ 3. В браузере: `<AgentChat … voice={createVoiceInput({ api: "/api/voice" })} />`, объект создаётся один раз.
1518
+ 4. Если скин свой: `Composer` получает `voice`; нет его — кнопку не рисовать.
1519
+ 5. Проверка: кнопка «Диктовать» есть в поле ввода, слова появляются по ходу речи.
1520
+
1521
+ ### Patch Changes
1522
+
1523
+ - Updated dependencies [b8f9e9f]
1524
+ - Updated dependencies [7c205c4]
1525
+ - @ykurilov/agent-chat-core@0.7.0
1526
+
1527
+ ## 0.6.0
1528
+
1529
+ ### Minor Changes
1530
+
1531
+ - 5252793: **Ломает приложения со своими хранилищами и своими скинами:** длинная переписка теперь сжимается сама, а у агента появился инструмент «поиск по старой переписке».
1532
+
1533
+ Что изменилось:
1534
+
1535
+ - Когда модель видит больше 200 тысяч токенов (у моделей с маленьким окном — 80% окна), ядро в фоне после ответа заменяет старую часть переписки короткой сводкой и оставляет свежие ~20 тысяч токенов как есть. Сообщения из базы не удаляются. В ленте на месте сжатия появляется строка «История сжата», по клику открывается сводка. Сжатие включено по умолчанию; `compaction: false` в `createChatHandler` (или `createChatService`) его выключает, `compaction: { thresholdTokens, tailTokens, summaryCeilingTokens, model, instruction }` настраивает.
1536
+ - Новый пакет `@ykurilov/agent-chat-history-search`: инструмент `search_earlier_conversation`, который ищет точные детали в оригиналах старой переписки. Подключается по желанию.
1537
+ - Новый адрес `GET /api/chat/:id/compactions` (в `createChatHandler`), новый метод службы `compactNow`, набор инструментов агента получает `chatId` хода.
1538
+ - «Стоп» обрывает ожидание сжатия сразу (само сжатие доделывается в фоне). `chat.flush()` при остановке сервера ждёт идущие сжатия до `compaction.flushWaitMs` (30 секунд по умолчанию) и отменяет остальные без записи полусводки.
1539
+ - Разделитель появляется без перезагрузки надёжно: сервер ставит признак «идёт сжатие» до конца потока ответа, а экран опрашивает `/compactions`, пока признак стоит (до 12 минут). `/compactions` стал лёгким: читает связи сообщений, а не тексты с выводами инструментов.
1540
+ - Сводку пишет самый малый уровень рассуждений модели (или без них, где можно), а потолок вывода имеет запас, так что рассуждения не обрывают сводку по длине. Пауза после сбоя теперь действует и на исход «нечего сжимать».
1541
+ - Поиск нормализует текст каждого сообщения один раз за вызов.
1542
+ - Дедупликация сжатий живёт в памяти процесса, как и реестр идущих ответов: при нескольких копиях сервера возможно двойное сжатие и двойной расход токенов.
1543
+ - Базы SQLite и PostgreSQL получают новую таблицу и новые поля сами при первом запуске. Ничего запускать вручную не нужно.
1544
+
1545
+ ### Как обновить приложение
1546
+
1547
+ 1. Обновите все пакеты SDK одной командой: `pnpm update "@ykurilov/agent-chat-*" --latest`. Базы обновятся сами при первом запуске сервера.
1548
+ 2. **Если у приложения своё хранилище** (свой класс, реализующий `ChatStorage`): добавьте три метода.
1549
+
1550
+ ```ts
1551
+ insertCompaction(record: StoredCompaction): Promise<void>; // записать отметку сжатия
1552
+ listCompactions(chatId: string): Promise<StoredCompaction[]>; // отметки чата по порядку добавления
1553
+ listMessageLinks(chatId: string): Promise<MessageLink[]>; // { id, parentId, createdAt } сообщений чата, в порядке listMessages
1554
+ ```
1555
+
1556
+ Типы `StoredCompaction`, `MessageLink` и общий тест `describeChatStorageContract` берутся из `@ykurilov/agent-chat-core`; тест теперь проверяет и эти методы.
1557
+ Приложения на памяти, SQLite и PostgreSQL из наших пакетов ничего не меняют.
1558
+ 3. **Если у приложения свой скин** (объект `ChatSkin` не из `@ykurilov/agent-chat-skin-tandem`): добавьте компонент `CompactionDivider`, договор скина теперь версии 4.
1559
+
1560
+ ```tsx
1561
+ const CompactionDivider: ChatSkin["CompactionDivider"] = ({ summary, open, onOpenChange }) => {
1562
+ const labels = useChatLabels();
1563
+ return (
1564
+ <div>
1565
+ <button type="button" aria-expanded={open} onClick={() => onOpenChange(!open)}>
1566
+ {labels.historyCompacted}
1567
+ </button>
1568
+ {open && (
1569
+ <div>
1570
+ <p>{labels.historyCompactedHint}</p>
1571
+ <pre>{summary}</pre>
1572
+ </div>
1573
+ )}
1574
+ </div>
1575
+ );
1576
+ };
1577
+ const skin = { ...mySkin, CompactionDivider };
1578
+ ```
1579
+
1580
+ `summary` — сводка в Markdown; свёрнутый разделитель — строка «История сжата». Скин Тандема обновляется вместе с пакетом.
1581
+ 4. **Если словарь надписей написан целиком**, а не через `{ ...russianLabels, … }`: добавьте ключи `historyCompacted` («История сжата») и `historyCompactedHint`.
1582
+ 5. Чтобы агент мог искать в сжатой переписке (по желанию): `pnpm add @ykurilov/agent-chat-history-search`, затем в `agent` обработчика добавьте `historySearchInstructions` в `instructions` и `...historySearch(model)` в `tools`; шаги и код — в разделе «Сжатие истории и поиск по старой переписке» файла `docs/getting-started.md`.
1583
+ 6. Проверка: `pnpm typecheck` проходит, чат открывается и отвечает. Кэш модели после первого сжатия обнуляется один раз (запрос после него дороже обычного), это ожидаемо.
1584
+
1585
+ ### Patch Changes
1586
+
1587
+ - Updated dependencies [5252793]
1588
+ - @ykurilov/agent-chat-core@0.6.0
1589
+
1590
+ ## 0.5.0
1591
+
1592
+ ### Minor Changes
1593
+
1594
+ - 62027c6: Ломает приложения: в поле ввода появился выбор модели и уровня рассуждений, поэтому «какой моделью отвечать» теперь решает не сервер приложения на каждый ход, а пользователь в чате. Настройка `resolveModel` заменена на `defaultChoice` (модель для нового чата).
1595
+
1596
+ Что нового простыми словами:
1597
+
1598
+ - Стандартный набор моделей: DeepSeek 4.1 Flash, GLM 5.3 Flash, GPT 6 Luna, Gemini 3.8 Flash. Провайдеры: Fireworks, DeepSeek, Together и новый OpenRouter (ключ `OPENROUTER_API_KEY`, среди хозяев модели выбирается самый быстрый).
1599
+ - В поле ввода одно меню моделей (провайдер → модель) и рядом выбор уровня рассуждений. Уровни свои у каждой модели: у DeepSeek «Выкл/Вкл», у GLM от «Низкий», у GPT и Gemini несколько уровней. У GPT рассуждения приходят зашифрованными: человеку виден только пересказ. На телефоне меню плоское («модель · провайдер»).
1600
+ - Выбор запоминается в чате. Модель можно сменить посреди чата: рассуждения прежней модели в запрос новой не уходят, но в базе и на экране остаются. Если сохранённой модели больше нет в каталоге, чат открывается на выборе по умолчанию, сообщения не меняются.
1601
+ - `extendCatalog` умеет больше: `removeProviders` (провайдер уходит, модель без провайдеров уходит вместе с ним) и `pinModelVersions` (закрепить версию модели у провайдера: `{ "gpt-6-luna": { openrouter: "openai/gpt-6-luna-2026-09-30" } }`). Ошибочная правка — ошибка при создании каталога.
1602
+ - Новые адреса сервера: `GET /api/chat/models` (список для меню, без ключей) и `PUT /api/chat/:id/model` (записать выбор чата).
1603
+ - Скин Тандема рисует `Composer` варианта `extended`. Договор скина стал версии 3: `Composer` получает необязательное `modelChoice`; свой скин без него работает как раньше. В `@ykurilov/agent-chat-react` есть хук `useModelChoice`.
1604
+ - Кэш: у Gemini через OpenRouter метка кэша теперь стоит и на последнем сообщении истории (скользящая точка, на следующем ходе переезжает на новое), поэтому кэшируется вся переписка, а не только инструкция. Для своего провайдера то же задаётся полем `lastMessageProviderOptions` в `requestOptions`.
1605
+ - Профиль модели получил отметку `toolResultImageProviders`: провайдеры, через которых модель видит картинку в результате инструмента (по живым замерам). У GPT 6 Luna и Gemini 3.8 Flash это `["openrouter"]`, у GLM 5.3 Flash и DeepSeek 4.1 Flash отметки нет. Ядро отметку не читает; по ней веб-инструменты решают, выдавать ли `page_screenshot`.
1606
+ - Если ключа выбранного провайдера временно нет, ответ идёт на запасной модели, но выбор пользователя в чате не перезаписывается: когда ключ вернётся, чат снова отвечает выбранной моделью.
1607
+ - Хранилища: у чата новое поле `reasoning` (уровень рассуждений). SQLite: колонка `chats.reasoning`, PostgreSQL: колонка `agent_chat.chats.reasoning`; миграции применяются сами при открытии базы, данные не теряются.
1608
+
1609
+ ### Как обновить приложение
1610
+
1611
+ 1. Сервер (там, где `createChatHandler` или `createChatService`). Ищите `resolveModel`.
1612
+
1613
+ Было:
1614
+
1615
+ ```ts
1616
+ createChatHandler({
1617
+ // …
1618
+ resolveModel: async () => ({ modelId: "deepseek-4.1-flash", providerId: "deepseek" }),
1619
+ catalog: standardCatalog,
1620
+ });
1621
+ ```
1622
+
1623
+ Стало:
1624
+
1625
+ ```ts
1626
+ createChatHandler({
1627
+ // …
1628
+ // Только для нового чата; дальше модель выбирает пользователь. Можно не задавать:
1629
+ // тогда берётся первая модель каталога у первого провайдера с ключом.
1630
+ defaultChoice: { modelId: "deepseek-4.1-flash", providerId: "deepseek" },
1631
+ catalog: standardCatalog,
1632
+ });
1633
+ ```
1634
+
1635
+ Раньше `resolveModel` мог выбирать модель по пользователю или чату. Теперь это делает пользователь в меню; ограничить выбор можно каталогом (`extendCatalog(standardCatalog, { removeProviders, removeModels, addModels, addProviders, pinModelVersions })`) и ключами на сервере: в меню попадают только провайдеры, чей ключ есть в окружении.
1636
+ 2. Ключи. Хотите GPT 6 Luna, Gemini 3.8 Flash или GLM через OpenRouter — добавьте `OPENROUTER_API_KEY` в окружение сервера. Без него этих моделей в меню не будет; остальное работает как раньше.
1637
+ 3. Свой словарь надписей. Если вы задали `ChatLabelsProvider labels={…}` не через `{ ...russianLabels, … }`, добавьте новые ключи: `reasoningLevels` (названия уровней: `off`, `on`, `minimal`, `low`, `medium`, `high`, `max`), `modelChoiceFailed` («Не удалось сменить модель») и `modelMenuFailed` («Список моделей не загрузился»). Через `{ ...russianLabels, … }` ничего делать не нужно.
1638
+ 4. Свой скин. Договор версии 3: `Composer` получает необязательное `modelChoice` (`providers`, `current`, `onChange`, `reasoningLevels`, `reasoning`, `onReasoningChange`, `error`). Не рисуете его — выбора модели в чате не будет, всё остальное работает. Подробно: `specs/003-models-and-picker/contracts/skin.md`. Скин Тандема (`tandemSkin`) обновляется вместе с пакетом, править нечего.
1639
+ 5. Своё хранилище (только если вы реализовали `ChatStorage` сами). Добавьте необязательное поле `reasoning?: string` в чат: `createChat` принимает и сохраняет его, `getChat` отдаёт (без значения — поля нет), `updateChatModel(chatId, { modelId, providerId, reasoning? })` записывает его целиком и без `reasoning` стирает сохранённый уровень. Наборы проверок `describeChatStorageContract` из `@ykurilov/agent-chat-core/storage-contract` проверяют это. Хранилища SQLite и PostgreSQL из SDK обновляются сами.
1640
+ 6. Проверка: `pnpm typecheck` проходит; в браузере в поле ввода есть меню моделей и выбор рассуждений; выбор остаётся после обновления страницы; сообщение отправляется и ответ приходит по частям.
1641
+ 7. Только если вы уже ставили `@ykurilov/agent-chat-web-tools` из main до этого выпуска: опция `screenshotProviders` убрана. Снимок страницы выдаётся по отметке `toolResultImageProviders` в профиле модели.
1642
+
1643
+ Было:
1644
+
1645
+ ```ts
1646
+ const webTools = createWebTools({ ...firecrawl, screenshotProviders: ["openrouter"] });
1647
+ ```
1648
+
1649
+ Стало:
1650
+
1651
+ ```ts
1652
+ const webTools = createWebTools(firecrawl);
1653
+ // Своя модель, у которой картинка из инструмента доходит через провайдера: добавьте отметку в профиль.
1654
+ // extendCatalog(standardCatalog, { addModels: [{ ...профиль, toolResultImageProviders: ["openrouter"] }] })
1655
+ ```
1656
+ - b059fcc: Переписку теперь можно хранить в PostgreSQL, а чат подключается к Next.js. Хранилища проверяются одним и тем же набором тестов.
1657
+
1658
+ - Новый пакет `@ykurilov/agent-chat-storage-postgres`: `createPostgresChatStorage({ connectionString })`. Таблицы лежат в отдельной схеме `agent_chat`, миграции применяются сами при создании, два сервера на старте друг другу не мешают.
1659
+ - В `@ykurilov/agent-chat-server` новая точка входа `@ykurilov/agent-chat-server/next`: `createNextRouteHandlers(chatHandler)` для файла маршрута App Router.
1660
+ - В `@ykurilov/agent-chat-core` набор проверок `describeChatStorageContract` дополнен: порядок сообщений при равном времени, изоляция чатов, очень длинный ответ, пустые поля. Хранилище в памяти для тестов остаётся в ядре, отдельного пакета нет.
1661
+ - В `@ykurilov/agent-chat-storage-sqlite` сообщения с одинаковым временем создания теперь читаются в порядке записи (раньше порядок между ними был не определён). Порядок держится на новой колонке `seq` в таблице `messages`, а не на внутреннем номере строки SQLite, который `VACUUM` вправе поменять. Миграция применяется сама при открытии базы: старые сообщения получают `seq` в том порядке, в каком лежали, данные не теряются.
1662
+
1663
+ ### Как обновить приложение
1664
+
1665
+ Только если вы берёте PostgreSQL или Next.js. Остальным обновление ничего не требует: SQLite и Hono работают как раньше, миграция SQLite (колонка `seq`) применится сама при первом запуске, делать с базой ничего не нужно.
1666
+
1667
+ 1. PostgreSQL вместо SQLite. В `server/index.ts` замените создание хранилища:
1668
+
1669
+ Было:
1670
+
1671
+ ```ts
1672
+ const storage = createSqliteChatStorage({ filePath: "chat.sqlite" });
1673
+ // при остановке: storage.close()
1674
+ ```
1675
+
1676
+ Стало:
1677
+
1678
+ ```ts
1679
+ import { createPostgresChatStorage } from "@ykurilov/agent-chat-storage-postgres";
1680
+
1681
+ const storage = await createPostgresChatStorage({ connectionString: process.env.DATABASE_URL! });
1682
+ // при остановке: await chat.flush(); await storage.close()
1683
+ ```
1684
+
1685
+ Адрес базы положите в `DATABASE_URL`. Пользователю базы нужно право создавать схему и таблицы. Переписку из SQLite пакет сам не переносит.
1686
+ 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».
1687
+ 3. Проверка: `pnpm typecheck` проходит, чат отвечает по частям, «Остановить» работает.
1688
+
1689
+ ### Patch Changes
1690
+
1691
+ - Updated dependencies [62027c6]
1692
+ - Updated dependencies [b059fcc]
1693
+ - Updated dependencies [61717c6]
1694
+ - @ykurilov/agent-chat-core@0.5.0
1695
+
1696
+ ## 0.4.0
1697
+
1698
+ ### Minor Changes
1699
+
1700
+ - 87c26eb: У обработчика чата появился метод `flush()`: он дописывает в базу остаток всех идущих ответов. Ядро теперь пишет идущий ответ не чаще раза в 0,5 секунды, поэтому при остановке сервера без `flush()` теряется до 0,5 с текста.
1701
+
1702
+ ### Как обновить приложение
1703
+
1704
+ Действие по желанию, но рекомендуется: при штатной остановке вызовите `await chat.flush()` после того, как сервер перестал принимать запросы, и до закрытия хранилища. Если приложение вызывало `service.flush()` из записи для `@ykurilov/agent-chat-core`, а сервис создаёт `createChatHandler`, вызывайте `chat.flush()`.
1705
+
1706
+ Было:
1707
+
1708
+ ```ts
1709
+ function shutdown() {
1710
+ server.close();
1711
+ storage.close();
1712
+ process.exit(0);
1713
+ }
1714
+ ```
1715
+
1716
+ Стало:
1717
+
1718
+ ```ts
1719
+ async function shutdown() {
1720
+ server.close();
1721
+ await chat.flush();
1722
+ storage.close();
1723
+ process.exit(0);
1724
+ }
1725
+ ```
1726
+
1727
+ Проверка: `pnpm typecheck` проходит; начните длинный ответ, остановите сервер сигналом `SIGTERM` — в базе текст ответа до момента остановки.
1728
+
1729
+ ### Patch Changes
1730
+
1731
+ - 0722f66: Если в адресе чата попадалась битая запись (например, `/api/chat/%E0`), сервер бросал необработанное исключение. Теперь он отвечает `404` с `{ "error": { "kind": "not_found" } }`, как на любой несуществующий чат.
1732
+
1733
+ Обновление ничего не требует.
1734
+ - Updated dependencies [ad8cf59]
1735
+ - @ykurilov/agent-chat-core@0.4.0
1736
+
1737
+ ## 0.3.0
1738
+
1739
+ ### Patch Changes
1740
+
1741
+ - Updated dependencies [a95b2d0]
1742
+ - @ykurilov/agent-chat-core@0.3.0
1743
+
1744
+ ## 0.2.0
1745
+
1746
+ ### Minor Changes
1747
+
1748
+ - ea50099: Первый выпуск Agent Chat SDK на складе GitHub Packages. Все пять пакетов теперь выходят вместе и
1749
+ с одной версией: «SDK 0.2.0» значит, что каждый пакет `@ykurilov/agent-chat-*` имеет версию 0.2.0.
1750
+
1751
+ Что изменилось по сравнению с тем, как пакеты подключали раньше:
1752
+
1753
+ - Пакеты приходят собранными (JavaScript и типы), а не исходниками TypeScript. Голый `node`
1754
+ запускает серверные пакеты без `tsx`.
1755
+ - Скин Тандема (`@ykurilov/agent-chat-skin-tandem`) теперь тоже пакет. Раньше папку `skins/tandem/src`
1756
+ копировали в приложение; копию можно удалить и подключить пакет.
1757
+ - Дизайн-система Тандем ставится вместе со скином как `@ykurilov/tandem-ui` (в коде остаётся
1758
+ импорт `@tandem/ui`).
1759
+
1760
+ ### Как обновить приложение
1761
+
1762
+ Если приложение подключало пакеты из этого репозитория без склада (ссылкой на папку или копией),
1763
+ переведите его на склад:
1764
+
1765
+ 1. В корне приложения в `.npmrc` оставьте одну строку:
1766
+
1767
+ ```ini
1768
+ @ykurilov:registry=https://npm.pkg.github.com
1769
+ ```
1770
+
1771
+ 2. Поставьте пакеты: `pnpm add @ykurilov/agent-chat-core @ykurilov/agent-chat-server @ykurilov/agent-chat-storage-sqlite @ykurilov/agent-chat-react`.
1772
+ 3. Если использовали скин Тандема из копии:
1773
+ - удалите папку с копией скина (например, `src/chat-skin`);
1774
+ - `pnpm add @ykurilov/agent-chat-skin-tandem "@tandem/ui@npm:@ykurilov/tandem-ui@^0.3.0"`;
1775
+ - в коде замените импорт `./chat-skin` на `@ykurilov/agent-chat-skin-tandem`;
1776
+ - в главном CSS-файле замените `@source` на папку скина на
1777
+ `@source "../node_modules/@ykurilov/agent-chat-skin-tandem/dist";`
1778
+ (путь считайте от CSS-файла до корня приложения).
1779
+ 4. Проверка: `pnpm typecheck`, сборка приложения, чат открывается и отвечает.
1780
+
1781
+ ### Patch Changes
1782
+
1783
+ - Updated dependencies [ea50099]
1784
+ - @ykurilov/agent-chat-core@0.2.0