applyElkLayout крутит elkjs СИНХРОННО в процессе на mxGraph-XML от LLM
(layout:'elk' в drawio_create/update) без лимита размера и без таймаута —
большой граф (тысячи узлов, ~1МБ XML проходит stage-1 cap 16МБ) блокирует
event-loop MCP-сервера на секунды-минуты. try/catch ловил только брошенную
ошибку, но не зависание.
- кап ДО построения графа: >500 узлов или >1000 рёбер → вернуть исходный XML
(best-effort, как существующий catch); синхронный elkjs → кап и есть
реальная защита;
- Promise.race с 5s-таймаутом (defense-in-depth на случай async-elkjs); таймер
гасится в finally → нет утечки хендла и unhandled-rejection (проигравший
timeout остаётся pending с погашенным таймером);
- тест: 600-узловой граф возвращается без изменений и быстро (<2s) — кап-путь.
79/79 drawio-тестов зелёные.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Стадия-1 (#434) уже была довяжена in-app в develop (f46d89ea, agent_vscode)
для CRUD-тулов; два новых чистых read-only хелпера стадии-2 остались
незаброшенными → contract-parity спека падала 6 ассертами (по 3 на
drawio_shapes/drawio_guide). В отличие от CRUD-тулов это ЧИСТЫЕ функции без
сетевого вызова, поэтому НЕ client-методы:
- реэкспорт searchShapes / getGuideSection (+ тип SearchShapesOptions) из
entry пакета @docmost/mcp; loadDocmostMcp() пробрасывает их так же, как
sharedToolSpecs (типы SearchShapesFn/GetGuideSectionFn);
- две записи sharedTool(...) в forUser() после drawioUpdate: drawioShapes
повторяет серверный вызов searchShapes(query,{category,limit}) и форму
{ query, count, results }; drawioGuide — getGuideSection(section)
(omit section -> index); голый объект без jsonContent-envelope, как у
соседних in-app хендлеров;
- DocmostClientLike и HOST_CONTRACT_METHODS-вайтлист НЕ тронуты (это не
методы клиента);
- три тест-мока (contract/service/tool-tiers) получили type-only no-op
заглушки под расширенный тип loadDocmostMcp() — тела инструментов в этих
тестах не исполняются, contract-спека реально гоняет настоящий
SHARED_TOOL_SPECS.
Внутреннее ревью обвязки: APPROVE, 0 находок. shared-tool-specs.contract:
211/211 (было 6 падений); client-host-contract drift-guard 3/0; tsc EXIT 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Финальный линк Фазы 1б. Инвентарь тулов жил в 4 рукописных прозаических копиях
(SERVER_INSTRUCTIONS под regex-тестом; <tool_catalog>; имена в ai-chat.prompt.ts
без гарда; README) — роадмап #416 планировал 4 последовательных ручных правки
этого текста (#411/#412/#413/#415).
- SERVER_INSTRUCTIONS разбит (новый модуль server-instructions.ts): ROUTING_
PROSE (рукописные intent-подсказки «когда что» — осмысленно ручные, перенесены
ДОСЛОВНО со всеми предостережениями: <=250 у create_comment, soft-delete у
delete_page, baseHash у drawio_update, PUBLIC у share_page) + buildToolInventory()
— генерирует <tool_inventory> из реестра (mcpName + purpose из catalogLine,
группировка по TOOL_FAMILY, бакет OTHER ловит незамаппленное → тул нельзя
тихо потерять) + 5 inline MCP-only (INLINE_MCP_INVENTORY). Детерминирован
(семейства FAMILY_ORDER, имена localeCompare). regex-тест server-instructions
удалён; структурные гарантии — в новом tool-inventory.test.mjs (точное
членство множества сильнее старого \b-скрейпа).
- Имена тулов в ai-chat.prompt.ts → через экспорт PROMPT_TOOL_NAMES; новый гард
ai-chat.prompt.tool-names.spec.ts: каждое имя — реальный тул реестра, скан
guidance-нот на camelCase-токены падает на несуществующем (escape-
нейтрализация против ложных nThe-токенов).
- INLINE_TOOL_TIERS уже содержал ровно 8 genuinely-inline тулов (после #445) —
сжатие не потребовалось.
Критерий: добавление/переименование спека меняет инвентарь БЕЗ правки прозы.
Внутреннее ревью: APPROVE — фактическим прогоном подтверждено, что НИ ОДИН тул
из старого SERVER_INSTRUCTIONS не выпал (диф старый-vs-новый пуст; добавился
get_workspace, раньше прятавшийся в EXCEPTIONS); проза дословна; инвентарь
полон/детерминирован/без фантомов; гард краснеет на обеих ветках провала.
613 node + 289 jest зелёные. Стоит на #445 — мержить последним в стопке 1б.
README-каталоги вне обязательного скоупа (docs-скрипт) — в чек-лист #412.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ядро Фазы 1б. Реестр (#294) шарил только метаданные (имя/схема/описание/tier),
но НЕ execute-логику — у каждого shared-тула было ДВА рукописных execute-тела
с копией маппинга аргументов (MCP registerShared в index.ts; in-app sharedTool
в ai-chat-tools.service, зеркалящий MCP-транспорт вручную). Корень
повторяющихся parity-багов (f46d89ea drawio, f8d26420 stashPage, fc9088b7
node-args): добавление одного тула = 7-9 согласованных ручных правок в двух
пакетах.
- SharedToolSpec расширен: канонический execute(client, args) (чистый JS —
свободно пересекает zod-мажорную границу v3/v4) + оверрайды
mcpExecute/inAppExecute/mcpOnly/inAppOnly для ОСОЗНАННЫХ per-layer различий.
client: DocmostClientLike (Pick из #446). Канон возвращает СЫРЬЁ, каждый хост
накладывает свой конверт (MCP jsonContent, in-app как есть); override владеет
результатом хоста целиком.
- Оба хоста → циклы по Object.values(SHARED_TOOL_SPECS): index.ts registerShared
39→0 (цикл), ai-chat-tools.service sharedTool ~40→1 (цикл). Добавление спека
автоматически регистрирует тул в ОБОИХ хостах — сценарий PR #434 невозможен
по построению.
- Осознанные различия через overrides (ни одно не сплющено к одному хосту):
оба mcpExecute+inAppExecute — createPage/movePage/deletePage/
exportPageMarkdown/createComment (guardrails, конверты, проекции, тексты
ошибок); execute+inAppExecute — getPage/renamePage/resolveComment;
execute+mcpExecute — stashPage (resource_link+structuredContent),
checkNewComments (since-guard только на MCP).
- Оставлены inline (по делу): update_comment/delete_comment (MCP-only, in-app
не даёт хард-правку/удаление комментов), search/transformPage (per-transport
дивергенция — hybrid RRF / без deleteComments), table_get (noun-vs-verb
naming clash — уедет после camelCase #412), getCurrentPage/updatePageContent/
listSidebarPages/getComment/getPageHistory (in-app-only, per-request state).
- Guard-тесты (contract-parity, phantom-catalog) сохранены — теперь инварианты,
не «последняя линия».
Внутреннее ревью: APPROVE, построчная BEFORE/AFTER-сверка по каждому shared-тулу
на обоих хостах — ни одного изменённого per-host поведения (порядок/дефолты
аргументов, guard'ы, конверты, проекции сохранены), множества тулов побайтово
совпадают (48 in-app, 45 MCP), кросс-zod-граница чистая (нет z. в execute),
611 mcp + 260 server тестов зелёные. Ядро Фазы 1б, стоит на #446 — мержить после.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Восстановленный отложенный долг #294: @docmost/mcp не отдавал .d.ts, поэтому в
сервере жили ТРИ дрейфующие ручные копии одних и тех же имён/сигнатур
(DocmostClientLike ~230 строк, копия SharedToolSpec, name-only HOST_CONTRACT_
METHODS-тест). In-app execute-тела зовут клиент ПОЗИЦИОННО, так что перестановка
параметра в client.ts доезжала до прода рантайм-ошибкой без сигнала на компиляции.
- declaration:true (+declarationMap) в packages/mcp/tsconfig.json; types-экспорт
в package.json (exports → conditional {types, default} для . и ./http;
require.resolve/dynamic-import резолвят default → build/index.js, рантайм не
тронут). build/index.d.ts эмитится, реэкспортит DocmostClient + SharedToolSpec.
Правок исходников пакета для эмита НЕ потребовалось.
- DocmostClientLike → Pick<DocmostClient, 48 методов> из type-only import
(стёрт на компиляции, ESM/CJS-границу не задевает); ручное зеркало удалено.
- SharedToolSpec → type-only реэкспорт из пакета; ручная копия удалена.
- client-host-contract.test.mjs удалён целиком — имена И сигнатуры теперь
проверяет tsc.
- Позиционная безопасность: never-called __assertClientCallContract(client:
DocmostClientLike) воспроизводит каждый позиционный вызов с типизированными
плейсхолдерами (AI-SDK стирает вход execute-замыканий в any, иначе позиционные
вызовы не проверялись). Перестановка параметров client.ts → ошибка компиляции
сервера ровно тут. Loose as-касты в ai-chat-tools.service не потребовали
правок; as any не добавлялся.
Внутреннее ревью: APPROVE. Runtime resolution через conditional exports не сломан
(разобрано для прод-инсталляции, не только symlink); покрытие
__assertClientCallContract полное (48 call-sites == union == assert, сверено
программно); Pick полон; демонстрация reorder → TS2345 в assert. Единственная
находка (Promise<any> в части возвратов) предсуществующая в client.ts, вне
цели PR. Стоит на #447 (закрытие skew build/vs/src) — мержить после него.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Два структурных слепых пятна: (1) сервер грузит СКОМПИЛИРОВАННЫЙ
packages/mcp/build/index.js, а серверные guard-тесты читают src/tool-specs.ts —
правка src без пересборки оставляет тесты зелёными, но рантайм расходится со
спеками; (2) спеки добавляют в packages/mcp, а parity-тесты живут в jest-сьюте
apps/server — PR, трогающий только пакет, проходит зелёным, сломанная in-app-
проводка всплывает уже на develop (кейс f46d89ea).
- REGISTRY_STAMP: codegen (scripts/gen-registry-stamp.mjs) считает sha256 от
нормализованного (CRLF→LF, один хвостовой \n снят) сырого текста
src/tool-specs.ts, пишет src/registry-stamp.generated.ts (gitignored),
index.ts реэкспортит → попадает в build/. Вшит в build/pretest/watch ДО tsc.
- Loader (dev/test): computeSrcRegistryStamp пересчитывает стамп из src рядом с
build/index.js (dev-vs-prod по existsSync, любая ошибка → null), сверяет с
build-стампом → при рассинхроне бросает «build is stale — rebuild». В prod
(src нет) и на pre-#447 билдах (нет REGISTRY_STAMP) — чистый no-op.
- CI: job mcp-server-parity собирает shared-deps+mcp (регенерит стамп) и гоняет
ОБА сьюта вместе (mcp node:test + server guard-спеки) — именованный гейт, его
нельзя случайно расщепить.
- AGENTS.md: правка спеков требует ребилда @docmost/mcp.
Тесты (20): mcp-сайд (детерминизм, нормализация, desync-гард стамп-vs-билд) +
server-сайд (null при отсутствии src = prod no-op; mismatch → throw точного
сообщения; pre-#447 no-op). Кросс-импл equality-гард: один фиксированный вход →
один хэш на ОБЕИХ сторонах, ловит рассинхрон двух нормализаций. Внутреннее
ревью: APPROVE WITH SUGGESTIONS (обе — покрытие guard'а — закрыты этим тестом).
Мутационно: любой из двух normalize-имплов расходится → equality-тест краснеет.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Третий класс петли (инцидент 2026-07-10): ран упёрся в 20-шаговый кап, спалив
все шаги на чтение; на 20-м шаге final-step lockdown отнял инструменты
(toolChoice:'none') посреди незаконченной работы → модель выродилась в
текст-повтор («loadTools.» ×20416, 255КБ). Пакет защит по дизайну владельца:
- MAX_AGENT_STEPS 20→50; спеки выводятся из константы (нет захардкоженных 19/20).
- Final-step lockdown под env-тогглом AI_CHAT_FINAL_STEP_LOCKDOWN (дефолт OFF,
по образцу AI_CHAT_DEFERRED_TOOLS): OFF — инструменты доступны на всех шагах +
мягкий финальный нудж; ON — легаси toolChoice:'none'+FINAL_STEP_INSTRUCTION.
Спеки параметризованы по тогглу. .env.example задокументирован.
- Пустой ход (все шаги без текста, шаги исчерпаны) получает синтетический
маркер-текст — виден в UI и реплее.
- Детектор токен-деградации в onChunk (единственная защита от болтовни, БЕЗ
maxOutputTokens — tool-аргументы это выходные токены): чистые правила
(≥25 одинаковых строк ИЛИ периодический хвост), при срабатывании abort через
внутренний AbortController ∪ effectiveSignal (AbortSignal.any), финализация в
onAbort: усечение хвоста, ai_chat_runs.error=Output degeneration detected,
лизы MCP/снапшоты освобождаются (существующий lifecycle).
- Предупреждение о бюджете шагов на MAX-6…MAX-2 с убывающим N.
- loadTools-описание и преамбула каталога явно говорят, что CORE-тулы всегда
активны (список из CORE_TOOL_KEYS динамически).
Внутренний цикл: 2 прохода. Ревью нашло data-loss-риск: правило периодичности
детектора ложно срабатывало на markdown-разделителях/setext-подчёркиваниях/
хвостовых пробелах (монохар-хвост p-периодичен при ЛЮБОМ p → ложный abort с
пометкой error и усечением). Починка: отдельная монохар-проверка (порог 60,
выше любого реального разделителя) + требование ≥2 различных символов в
периодическом блоке при p≥2. Реальный loadTools-цикл (период ~10) ловится.
Мутационно: 59 одинаковых — не флаг, 60 — флаг; loadTools×20416 — флаг.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR #434 (drawio stage 1) added drawioGet/drawioCreate/drawioUpdate to the
shared tool-spec registry with in-app metadata (inAppKey, deferred tier,
catalogLine) but wired them only in the standalone MCP server, breaking the
contract-parity and phantom-catalog unit tests on develop CI.
- expose drawioGet/drawioCreate/drawioUpdate in forUser() via sharedTool(),
mirroring the MCP transport's argument mapping (format ?? 'xml' default,
flat schema regrouped into the client's `where` object, positional
baseHash pass-through)
- extend the DocmostClientLike hand-mirror with the three client methods
- append the three names to the HOST_CONTRACT_METHODS drift-guard whitelist
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Аудит при подготовке #413 нашёл дрейфующие дубли между packages/mcp и
packages/prosemirror-markdown. Четыре дедупа (поведение тулов не меняется):
1. node-ops: форк ~960 строк сведён в ОДНУ копию в prosemirror-markdown (живая
mcp-версия — строгое надмножество замороженного #293-seed'а пакета; сверено по
git-истории, новая пакет-копия байт-в-байт == прежней mcp-копии). Barrel-экспорт
полной поверхности; mcp/client.ts/page-search.ts/transforms.ts/collaboration.ts
импортируют из пакета; тесты переехали. node-ops тянет stripInlineMarkdown ->
пакет-локальная text-normalize.ts несёт только этот примитив (mcp-версия —
домен #408; заголовок документирует дубликацию + источник истины).
2. footnote-lex/footnote-analyze (vestigial legacy [^id]: диагностика): сведены к
одному fence-aware предупреждению 'reference-style footnotes -> use ^[...]'
(полезно для класса #410); footnote-lex удалён.
3. footnote-authoring -> примитивы (footnoteContentKey/makeFootnoteDefinition/
generateFootnoteId) перенесены в пакетный footnote.ts, одна реализация конвенции.
4. parse-node-arg -> перенесён в prosemirror-markdown (не mcp: сервер CommonJS не
импортирует ESM-only @docmost/mcp, но нативно импортирует пакет), обе копии
удалены, консьюмеры перенаправлены.
canonicalizeFootnotes/ENFORCEMENT RULE #228 и comment-anchor/json-edit/text-normalize
(mcp) не тронуты. API-поверхность node-ops оставлена чистой для #409/#413.
closes#414
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wrapToolsWithCommentSignal всегда ставил свой toModelOutput, молча выбрасывая
собственный toModelOutput инструмента (латентная ловушка — будущий тул со своим
toModelOutput тихо сломался бы). Теперь база = origToModelOutput(info) при наличии,
иначе воспроизведённый дефолт SDK; no-signal путь возвращает базу дословно, signal-
путь = части базы (modelOutputToParts: text/json/content) + элемент сигнала последним.
execute по-прежнему возвращает СЫРОЙ результат -> part.output/цитаты байт-идентичны.
Дефолтный путь (единственный исполняемый сегодня) байт-идентичен и SDK-дефолту, и
до-фиксовому signal-пути (проверено повторным ревью). json-ветку загардил ?? null
для симметрии с fallback. +2 теста: тул со своим text/content toModelOutput —
база честно сохраняется и в no-signal, и в signal (сигнал добавлен последним).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Комментарии человека находят агента сами: короткая эфемерная строка
'new comments: N on page … — call listComments(pageId)' в результате ЛЮБОГО
tool-вызова, mid-turn. Общий хелпер packages/mcp/src/comment-signal.ts
(createCommentSignalTracker: watermark + per-page debounce + working-set;
buildCommentSignalLine; defangCommentSignalTitle).
- Standalone MCP (index.ts): второй wrapper в choke point registerTool (паттерн
метрик #402) — отдельный {type:'text'} content-элемент, форма результата не
меняется. Источник: rate-limited listComments по working-set, title через
getPageRaw только на hit. State per-session.
- In-app (ai-chat-tools.service.ts): execute ВСЕГДА возвращает сырой результат
(part.output/цитаты не трогаются), сигнал доставляется модели через отдельный
toModelOutput ({type:'content', value:[raw, signal]}) — зеркало MCP; no-signal
ветка точно воспроизводит дефолт SDK. Источник: REST-probe (осознанный форк от
DB-count из ТЗ — чтобы не менять конструктор сервиса и не ломать спеки).
- Инъекционная защита: в сигнал идут только count+pageId+defanged-title, НИКОГДА
текст комментария (untrusted). Per-page watermark (не глобальный) — комментарии
на второй странице не теряются.
closes#417
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
В ai@6 упавший тул — это tool-error часть в step.content ({type,toolCallId,
toolName,input,error}), а не элемент toolResults. Раньше serializeSteps писал
только toolCalls+toolResults (ошибка терялась, orphan tool-call без результата),
а assistantParts эмитил заглушку 'Tool call did not complete.' (реальный текст
ошибки терялся для мультиходового реплея — модель не знала, почему упало, и
повторяла ошибку).
- StepLike расширен полем content; новый хелпер normalizeToolError (Error/string/
object -> строка, обрезка через существующий compactValue/лимиты).
- serializeSteps: на каждый tool-error пушит парный {toolName, error} тем же
паттерном, что успешный {toolName, output} -> колонка tool_calls фиксирует сбой.
- assistantParts: при наличии tool-error эмитит output-error с РЕАЛЬНЫМ текстом;
заглушка остаётся только для по-настоящему непарных вызовов (прерванных).
- docs/reading-ai-logs.md обновлён под новую форму + cutover-оговорка.
Обратно совместимо: старые строки читаются как раньше, error-элемент аддитивен.
closes#407
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Read-tool outputs were compacted at a 4000-byte gate before being stored
in metadata.parts, which is re-sent to the model every later turn. Whole-page
reads (tens of KB) got shrunk to a 500-char preview plus a "[truncated N chars]"
marker; on the next turn the model read that marker as a source truncation and
re-read the page, wasting tokens and producing wrong behavior.
- Raise MAX_TOOL_OUTPUT_BYTES 4000 -> 200_000 so normal reads are stored and
replayed verbatim; only a single >200 KB output is compacted as a backstop.
- Reword the inline string marker so it reads as a replay-history elision, not
a source truncation, and tells the model it can re-call the tool.
- Update doc comments and enlarge the compactToolOutput unit-test inputs above
the new 200 KB gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Researcher role wrote 40 literal `^[...]` and zero real footnotes: its
incremental write path (insertNode/editPageText) doesn't parse markdown, and
the footnote-capable tool was MCP-only. Promote three tools from inline
MCP-only to the shared registry so the in-app agent gets them too.
- tool-specs.ts: insertFootnote/insertImage/replaceImage added to
SHARED_TOOL_SPECS (mcpName/schema/description moved VERBATIM from the inline
registrations — MCP names + behaviour unchanged for external clients).
- index.ts: the 3 inline registerTool calls become registerShared; drop the
"MCP-only by design" comments.
- ai-chat-tools.service.ts: register the 3 in-app via sharedTool ->
client.insertFootnote/insertImage/replaceImage (imageUrl->url,
attachmentId->oldAttachmentId mapping).
- tool-tiers.ts: insertFootnote -> core (else the original asymmetry recurs —
footnote tool hidden while editPageText is core); images -> deferred.
- research/{en,ru}.yaml FOOTNOTES: `^[...]` parses ONLY on a whole-markdown
write (create/update/import); for a pinpoint citation to existing text use
insertFootnote; via editPageText/insertNode it stays literal.
- json-edit.ts guardrail: an edit_page_text `replace` containing a `^[...]`
token is refused into failed[] with an insert_footnote hint, mirroring the
existing formatting-marker refusal. (Slightly broader net than that mirror —
a literal `^[a-z]` regex class in a replace is also refused; accepted
defense-in-depth, has a no-false-positive test.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the merged MCP-hang fix (16b476a2); post-hoc review DO. The
fix itself is unchanged and prod-working — this adds the coverage + one
coherence fix the review asked for.
Tests (ai-chat.service.setup-abort.spec.ts):
- onLateResolve late-close: a toolset that resolves AFTER the setup race
was lost has its leased clients released (close spy asserted).
- pure 60s deadline (signal NOT aborted): the turn proceeds Docmost-only
(reaches streamText, run NOT finalized 'aborted') — the defense-in-depth
backstop, previously untested.
- legacy no-runId: a setup abort does NOT re-throw (the `runId &&` guard);
together with the deadline test this locks both halves of the catch guard.
Coherence (external-mcp/mcp-clients.service.ts buildEntry):
- The per-server connects now run via Promise.all instead of a sequential
for-await, so total build time is bounded by the slowest single server
(~2×CONNECT_TIMEOUT_MS) instead of the sum. At >6 all-timing-out servers
the sequential build exceeded the outer 60s deadline, inverting the
"per-server bound primary, outer deadline is backstop" invariant. Merge
is done in a sequential post-Promise.all loop in server order, so tool-key
precedence/disambiguation, outcomes and client ordering are byte-identical
to the sequential build; each server keeps its own timeout + failure-close.
Comments: the onLateResolve note now says it releases the lease (refcount),
not force-closes transports (the cache owns them); the invariant comment
reflects the parallel build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single NUL (U+0000) in model/tool output (e.g. a truncated multibyte
read of a web page) is rejected by Postgres in BOTH the content (text) and
toolCalls/metadata (jsonb) columns, so it failed EVERY write of the
streaming assistant row ("invalid input syntax for type json") and silently
dropped the turn's content from the DB while the live stream still showed it.
- add stripNulChars: deep-strips NUL from all strings, returns the same
reference when there is nothing to strip (no needless clone)
- apply it at the flushAssistant choke point (covers content + toolCalls +
metadata for the seed, per-step and terminal writes)
- tests: deep-strip, same-reference, end-to-end via flushAssistant
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reduce the default external-MCP silence timeout from 5 min to 1 min and the
overall call timeout from 15 min to 2 min. Update related tests and comments.
Increase Fastify's JSON body limit to 25 MiB (configurable via
HTTP_JSON_BODY_LIMIT) to accommodate large AI‑chat payloads.
BREAKING CHANGE: shorter MCP timeouts may abort long‑running tool calls that
previously succeeded with the older defaults.
A transient network blip during an external-MCP handshake left createMCPClient
pending forever (@ai-sdk/mcp does not settle on abort). getOrBuildEntry caches the
per-workspace build PROMISE, so the never-settling connect poisoned the cache and
every later turn hung at step_count=0 before streamText — the run never finalized,
the row stayed 'running', and the chat was permanently blocked with
A_RUN_ALREADY_ACTIVE (an explicit Stop could not interrupt the un-signalled setup).
- mcp-clients: wrap connect() in a settling timeout (connectWithTimeout) that
closes a late-arriving client, so a hung handshake rejects instead of poisoning
the build cache; the bad server is skipped and the build completes.
- mcp-clients: close a connected-but-unregistered client when tools() fails,
fixing a pre-existing transport leak in buildEntry.
- ai-chat.service: bound the toolsFor build with the run's abort signal AND a
deadline (raceAgainstAbortAndTimeout); re-throw an explicit Stop only when a run
exists (runId) so the run finalizes as 'aborted', and keep the legacy
socket-bound path unchanged; settle the run 'aborted' vs 'error' accordingly.
- tests: cache-not-poisoned + orphan-client-close-once + Stop-during-setup
finalizes the run once.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Snapshot the editor selection at send time (same live-ref pattern as
openPageRef in prepareSendMessagesRequest), carry it nested inside
openPage so it dies with the page on a fail-closed resolve, and surface
it to the model only through the existing core getCurrentPage tool.
The selection TEXT is returned exclusively in the tool result (untrusted
collaborative-page content, treated as data by SAFETY_FRAMEWORK); the
system prompt gets only a fixed one-line flag, never the text/before/
after. sanitizeSelection caps text (4000), before/after (200), blockIds
(<=64 chars, <=20). Selection is a hint, not ground truth — the tool
description tells the agent to localize the fragment before editing.
closes#388
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Do 1 [test-coverage]: контроллерный тест на флаг-гейт begin-hook open() — при
OFF beginRun зовётся (durable-ран независим), а streamRegistry.open НЕ зовётся
(закрывает регресс: пустая entry → non-null paused attach → зависший SSE вместо
204); при ON — open зовётся с (chatId, runId).
Do 2 [stability]: байт-кап очереди pending paused-подписчика. pendingBytes +
overflowed на Subscriber; в paused-ветке ingestFrame при превышении
SUBSCRIBER_MAX_BUFFERED_BYTES (8MB) подписчик помечается overflowed, pending
чистится, он выбрасывается из entry.subscribers (как overflowed-entry). start()
на overflowed → onEnd (чистый 204-эквивалент, без частичного реплея). Контракт
«start() в том же тике, что attach()» задокументирован в коде — кап это
структурный бэкстоп для phase-2 Redis-await шва. Юнит-тест: paused A + live B,
9×1MB > cap → A выброшен (0 доставок), B получает все 9 живьём, поздний start(A)
→ один onEnd без реплея.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR 1 of 2 (#381, фаза 1.5 #184): серверный реестр SSE-стримов агентских ранов,
чтобы любая вкладка могла подключиться к живому рану с реплеем кадров + живым
хвостом. «Спящий» — весь провод за флагом AI_CHAT_RESUMABLE_STREAM (off по
умолчанию); клиент (PR 2) ещё не написан.
- ai-chat-stream-registry.service.ts: in-memory реестр (open/bind/abortEntry/
attach). attach — снапшот+подписка в ОДНОМ синхронном блоке (инвариант 4: нет
await между `subscribers.add` и `frames.slice()`), paused-подписчик, overflow,
retention с identity-guard (инвариант 2), open поверх live entry даёт ровно
один onEnd (инвариант 3), anchor против кросс-ранового реплея (инвариант 6).
- ai-chat.controller.ts: begin-хук open(chatId, runId) + GET-attach эндпоинт
(403 чужой чат; 204 нет-entry/finished/anchor-мисматч; cleanup до первой
записи + recheck req.raw.destroyed; cap→destroy).
- ai-chat.service.ts: tee SSE-кадров в реестр (consumeSseStream + generateMessageId,
гейт на runId && flag) + abortEntry из внешнего catch.
- environment.service.ts: флаг isAiChatResumableStreamEnabled().
Флаг OFF ⇒ байт-в-байт legacy И #184-фаза-1 (нет start.messageId, нет tee).
Инжектируемые провайдеры НЕ @Optional() → поломка вайринга роняет старт, а не
тихо выключает фичу.
Тесты: registry unit (16), controller.attach (9), service pipe-options (4, вкл.
flag-off-with-runId негатив), int-spec ai-chat-attach (6, реальный MockLanguageModelV3).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Integrates the designer-handoff Roles Catalog modal, wired to the real API; the
parent ai-agent-roles.tsx and the { opened, onClose, roles } contract are
unchanged.
- Server importFromCatalog now returns per-role lists (createdRoles /
skippedRoles with a reason) alongside the existing counters (compat-preserving),
so the UI can name the conflicting/installed roles.
- New pure view-model (catalog-bundle-model.ts): bundlePhase (empty | allNew |
allInstalled | updates | mixed, ignoring the transient 'skipped'),
installedLangForRole (same-slug-different-language hint), mapCatalogRoleToView —
all unit-tested without mounting.
- Bundle cards with a summary status in the collapsed header (eager useQueries
fan-out over all bundles, sharing the existing per-bundle cache keys), a single
primary action per bundle, checkboxes + select/deselect-all, an inline result
plaque that keeps the modal open, per-bundle and global 'Update all' request
series with progress, and the other-language hint.
- The partial-result plaque distinguishes the skip reason: only a name-conflict
offers 'Rename & install'; an already-installed race is informational (a rename
re-import would just skip again and self-heal into a false success).
- All strings i18n'd (en/ru); mock handoff code (SEED/mockImport/delay) removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- F1: added in-app execute tests for the two wirings that ACTUALLY changed in the
migration (the contract-parity test only checks advertised schema keys, not
execute bodies): movePage forwards the newly-added optional `position` to
client.movePage (and passes undefined position + null parent when omitted); the
table trio (insert/delete/updateCell) forwards the unified `table` param
positionally. A field destructured under the wrong name would have silently
passed undefined to the client (execute is any-cast, tsc won't catch it).
- F2: rewrote the three migrated descriptions that hardcoded snake_case sibling
tool names (which the in-app camelCase layer exposes under different ids,
violating the registry's own transport-neutral-prose convention) into neutral
prose: getPage "use get_page_json" -> "use the lossless page-JSON read tool";
updatePageJson "get_page_json -> ... -> update_page_json" -> "read the page-JSON
view -> modify -> write it back", "prefer rename_page" -> "prefer the rename-page
tool"; exportPageMarkdown "import_page_markdown round-trip" -> "page-Markdown
import round-trip" (the last was a direct regress — the in-app base said the
camelCase importPageMarkdown). (stashPage's pre-existing get_page_json mention is
out of scope, per the reviewer.)
Gate: mcp build 0; ai-chat-tools.service + tool-tiers (catalog-partition) pass,
incl. the 5 new execute-wiring tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrates share_page / sharePage into the transport-agnostic spec registry
(schema + description declared once; each transport keeps only its execute/auth):
- sharePage (deferred) -> SHARED_TOOL_SPECS; index.ts uses registerShared(),
ai-chat uses sharedTool(); removed from INLINE_TOOL_TIERS.
Drift reconciled (documented inline): both inline copies already carried the
"only share when the user explicitly asked" security framing, so the old
"per-transport divergence" note in BOTH layers was STALE — there was no real
behavioral divergence, only wording drift. The canonical description merges the
MCP copy's URL-format + idempotency detail with the in-app copy's reversibility
note and keeps the shared security framing. pageId keeps the MCP copy's stricter
.min(1). The MCP execute keeps its own `searchIndexing ?? true` default
(per-layer, not part of the shared schema).
Intentionally NOT migrated (kept inline — genuinely divergent, as their existing
notes state):
- search / searchPages: the in-app tool is a semantic+keyword hybrid (RRF) with
in-process access control and a tuned schema (limit 1-20); the MCP `search` is
a plain REST full-text search (limit up to 100). Different behavior AND schema.
- docmost_transform / transformPage: the in-app tool deliberately omits the
`deleteComments` schema field (a comment-deletion guardrail) and carries a
shorter description. Different schema.
Gate: mcp build 0 + node --test 458/458 (page-search excluded — hangs only under
the local re2->RegExp type-shim, its source untouched), server jest 775 incl.
tool-tiers catalog-partition + shared-spec contract parity, server tsc 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrates the three-layer page tools into the transport-agnostic spec registry
(schema + description declared once; each transport keeps only its execute/auth):
- getPage, listPages (core), createPage, movePage, renamePage, deletePage,
updatePageJson, exportPageMarkdown (deferred) -> SHARED_TOOL_SPECS; index.ts
uses registerShared(), ai-chat uses sharedTool(); removed from
INLINE_TOOL_TIERS. Tiers preserved from CORE_TOOL_KEYS (getPage/listPages =
core, the rest deferred).
delete_page is genuinely three-layer (in-app deletePage exists), so it IS
migrated — not MCP-only. Its H4 guardrail is preserved: the shared schema
exposes ONLY pageId, so no permanentlyDelete/forceDelete flag can reach the
client (still asserted by ai-chat-tools.service.spec.ts).
Descriptions merged (documented inline): each canonical text takes the MCP
copy's richer structural notes plus the in-app copy's reversibility framing.
Schema DRIFT reconciled (documented inline):
- createPage.content: MCP pinned .min(1) but the in-app copy left it unbounded
and DOCUMENTS an empty body as valid ("may be empty" — creating an empty page
to fill later is a real use). Kept the looser no-min form: create_page now also
accepts an empty body (harmless) and no previously-valid in-app input is
rejected. title/spaceId keep the MCP .min(1) (empty is never valid).
- movePage: MCP exposed an optional `position` (fractional-index) field the
in-app copy lacked. Unified by KEEPING position — the in-app client already
accepts an optional position arg, so the in-app execute now forwards it;
optional, so no previously-valid call is rejected. `parentPageId` is nullable
on both (real JSON null -> root); the MCP execute keeps its 'null'/'' string
coercion as a per-layer robustness fallback.
- getPage/renamePage/updatePageJson/exportPageMarkdown/listPages: kept the MCP
copy's stricter .min(1) on ids where the in-app copy was unbounded.
Per-transport execute logic preserved: getPage's {title,markdown} projection,
updatePageJson's JSON-string normalization, list_pages' default limit/tree, and
move_page's cycle guard + positive-confirmation check all stay in their execute
bodies.
Intentionally NOT touched: updatePageContent (Markdown-based body update; no MCP
equivalent) and getTable (name-convention divergence, see tables family) stay
inline.
Gate: mcp build 0 + node --test 458/458 (page-search excluded — hangs only under
the local re2->RegExp type-shim, its source untouched), server jest 770 incl.
tool-tiers catalog-partition + shared-spec contract parity + deletePage H4
guardrail, server tsc 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrates the three-layer table WRITE tools into the transport-agnostic spec
registry (schema + description declared once; each transport keeps only its
execute/auth):
- tableInsertRow, tableDeleteRow, tableUpdateCell -> SHARED_TOOL_SPECS;
index.ts uses registerShared(), ai-chat uses sharedTool(); removed from
INLINE_TOOL_TIERS (all three are deferred; not in CORE_TOOL_KEYS).
Drift reconciled (documented inline): the four table tools previously carried a
"NOT shared" note in both layers over a single parameter-NAME drift — the MCP
layer named the table reference `table`, the in-app layer `tableRef`. Unified on
the MCP name `table` (renaming the public MCP parameter would break external MCP
clients; the in-app parameter is model-facing/prompt-only and safe to rename).
The in-app execute bodies now destructure `table`. Descriptions took the MCP
copy's richer wording (documents `#<index>`, padding, header-row behavior) plus
the in-app copy's "Reversible via page history" note; both fields keep the MCP
copy's stricter .min(1) (in-app left them unbounded); sibling tool references
phrased transport-neutrally.
Intentionally NOT migrated (kept inline): table_get / getTable. Its MCP tool
name is noun-first (`table_get`) while the in-app key is verb-first (`getTable`),
which breaks the snake_case(inAppKey) naming convention the registry enforces
(shared-tool-specs.contract.spec.ts). Renaming the public MCP tool would break
external clients, so it stays per-transport — but its in-app reference param was
still aligned to `table` (was `tableRef`) for consistency with the migrated trio.
Gate: mcp tsc 0 + node --test 458/458 (page-search excluded — hangs only under
the local re2->RegExp type-shim, its source is untouched), server jest 730 incl.
tool-tiers catalog-partition + shared-spec contract parity, server tsc 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrates the three-layer comment tools into the single transport-agnostic spec
registry (schema + model-facing description declared once; each transport keeps
only its execute/auth):
- createComment, listComments, resolveComment, checkNewComments — moved to
SHARED_TOOL_SPECS; index.ts uses registerShared(), ai-chat uses sharedTool();
removed from INLINE_TOOL_TIERS (tier/catalogLine now on the spec). Tiers
preserved from CORE_TOOL_KEYS (create/list/resolve = core, check = deferred).
Intentionally NOT migrated (kept MCP-inline): update_comment / delete_comment —
they are MCP-only by design; the in-app AI-chat layer deliberately has no
updateComment/deleteComment (comment edits are irreversible / not
version-tracked), asserted by ai-chat-tools.service.spec.ts. A registry spec's
tier/catalogLine are in-app metadata and the catalog-partition test forbids a
deferred spec without a live in-app tool, so these stay per-transport.
Drift reconciled (documented inline): createComment/listComments/checkNewComments
took the more-maintained/superset description + stricter .min(1) guards.
resolveComment: `resolved` drifted (MCP optional+default(true) vs in-app
required) — kept the MCP superset, so in-app resolveComment now accepts an
omitted `resolved` (defaults to resolve) — a deliberate, backward-compatible
unification (never rejects a previously-valid input).
Gate: mcp build 0 + node --test 480/480, ai-chat 654, tool-tiers (incl. F3
catalog-partition) 16/16, server tsc 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Squashed for a clean rebase onto develop (was 19 commits; the reviewer approved
the net diff at fb246080). Detaches an agent run from the HTTP request/browser
window: a run is a first-class lifecycle object (ai_chat_runs), a browser
disconnect no longer kills it, a concurrent-run insert-gate prevents double runs,
and a reopened chat live-follows a still-running run via a polled observer merge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- F1: document AI_CHAT_DEFERRED_TOOLS in .env.example (AI_* section) — default
ON = deferred loading (compact catalog + loadTools), =false restores the old
"all tools always active" behavior.
- F2: integration test of the ON path in ai-chat-stream.int-spec.ts — a deferred
tool activated via loadTools is active on the SAME turn's next step but a fresh
turn starts cold (CORE + loadTools only), proving the per-turn activatedTools
Set does not leak across turns/chats. Drives the real streamText loop with a
MockLanguageModelV3 and inspects recorded per-step activeTools-filtered tools.
- F3: replace the magic toHaveLength(28) in tool-tiers.spec.ts with a two-way
partition against the LIVE in-app toolset (AiChatToolsService.forUser keys):
every non-core tool must appear in buildInAppDeferredCatalog and every catalog
entry must map to a real non-core tool — so a future tool forgotten in
INLINE_TOOL_TIERS fails the suite instead of silently vanishing from the agent.
No production logic change (mechanism was already reviewed correct).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The in-app AI agent shipped all ~41 tool schemas on every model step. This
adds a two-tier catalog: core tools (frequent or one-line) stay always-active;
the rest are advertised as a compact catalog and their full schema is fetched
on demand via the loadTools meta-tool, wired through ai@6 prepareStep's
per-step activeTools.
- tools/tool-tiers.ts: CORE_TOOL_KEYS, INLINE_TOOL_TIERS, applyLoadTools,
catalog builders (+ tool-tiers.spec.ts, 13 cases).
- ai-chat.service.ts prepareAgentStep: returns activeTools =
[...CORE_TOOL_KEYS, loadTools, ...activatedTools]; per-turn activated Set.
- ai-chat.prompt.ts: buildToolCatalogBlock renders the deferred catalog.
- mcp/tool-specs.ts: tier + catalogLine metadata (external snake_case /mcp
transport unchanged).
- EnvironmentService.isAiChatDeferredToolsEnabled(): AI_CHAT_DEFERRED_TOOLS,
default ON per issue intent (kill-switch =false restores old behavior).
Gate: server ai-chat 631/631, tool-tiers 13/13, mcp 472/472, tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Editorial roles (Corrector/Factchecker) brute-forced `get_node` block-by-block to
find occurrences (unquoted «ё», straight quotes, «т.е.»), burning tokens. New
`search_in_page(pageId, query, {regex?, caseSensitive?, limit?})` reads the page's
ProseMirror JSON via the existing getPageRaw and searches it IN MEMORY — no server
endpoint, no DB/schema change, no touch to the packages/mcp/src/lib schema mirror.
New pure `searchInDoc(doc, query, opts)` (packages/mcp/src/lib/page-search.ts):
recursive descent to each TEXT CONTAINER (paragraph/heading/table-cell paragraph),
glues its inline text via `blockPlainText` (a match survives inline-mark
boundaries — e.g. «т.е.» split across bold/italic), searches literal (indexOf) or
regex, and returns `{ total, truncated, matches:[{ nodeId, blockIndex, type,
before, match, after }] }`. `nodeId` is the container's attrs.id or the
`#<topLevelIndex>` of the enclosing top-level block — the SAME ref format
get_node/patch_node/comment-anchoring accept (verified identical to getNodeByRef),
so the agent goes straight from a hit to a targeted comment; `before`/`after` are
~40-char windows for a unique selection. `total`/`truncated` always reported (never
silent truncation). Lives in the SHARED_TOOL_SPECS registry → exposed in BOTH
transports (external /mcp + in-app AI-chat), with a SERVER_INSTRUCTIONS line and a
DocmostClientLike signature + contract-test entry. Corrector/Factchecker prompts
get a one-line "use search_in_page first" hint (versions bumped, catalog hash lock
refreshed).
Guards: empty/whitespace query → clear error; invalid regex → clear error (not a
generic 500); zero-length regex matches (`\b`, `a*`) skipped with lastIndex
advanced (no loop/flood); MAX_PATTERN_LENGTH=1000, MAX_CONTAINER_TEXT=100k bound
each exec; limit clamped [1,200] (default 50).
Tests: new page-search.test.mjs (17) — literal+regex, case-sensitivity,
mark-boundary glue, nodeId for paragraph/heading (attrs.id) and table-cell
(#<index> fallback), context bounds, limit/total/truncated + clamp, invalid
regex/empty/over-long errors, zero-length skip, empty-doc null-safety.
mcp: tsc clean; node --test 467 passed (+17). apps/server: tsc --noEmit clean
(DocmostClientLike + wiring). catalog check.mjs OK.
Known limitations (from internal review, non-blocking):
- Residual ReDoS: a crafted catastrophic-backtracking pattern (e.g. `(a+)+$`)
against a large single container can hang the event loop — JS regex is not
interruptible, so the length caps bound the base but not the backtracking.
Realistic exposure is low (containers are small; the pattern is supplied by the
authenticated model). Candidate for a follow-up hardening (safe-regex validation
or a worker+timeout) if it matters.
- Case-insensitive LITERAL search folds via toLowerCase; a char whose lowercase
differs in length (e.g. Turkish İ) BEFORE a match could shift the context
window — negligible for the RU/EN editorial scenario.
- On a `#<index>` table-cell fallback, `type` is the inline container ("paragraph")
while nodeId addresses the top-level block — addressing is correct; the field is
documented as the container's type.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The AI agent (MCP + in-app chat) saw ALL comments incl. resolved via two
channels, cluttering its context and breaking fragment search. Default now:
the agent sees only ACTIVE discussions; resolved is opt-in. Active anchors and
threads are always kept.
Channel 1 — resolved comment anchors on agent reads (converter option):
`convertProseMirrorToMarkdown(content, options?)` gains
`options.dropResolvedCommentAnchors` (default false — zero change for every
existing caller incl. git-sync). Both `case "comment"` emitters (top-level and
the raw-HTML inlineToHtml path) emit BARE text (no `<span data-comment-id>`) when
`resolved && the flag`; active anchors keep their wrapper. mcp `getPage` passes
the flag; `export_page_markdown` does NOT (lossless export must preserve resolved
anchors — that is why it is an opt-in option, not unconditional); `get_page_json`
is untouched (lossless PM JSON). Built on the #293 package converter.
Channel 2 — `list_comments` default active-only: `listComments(pageId,
includeResolved=false)` now returns `{ items, resolvedThreadsHidden }` (was a
bare array). By default a RESOLVED top-level thread is hidden wholesale — the
root AND every reply anchored to it (a thread is gated only by its root's
resolvedAt; a resolved reply under an ACTIVE root stays). `resolvedThreadsHidden`
counts hidden threads so the agent knows to re-query. `includeResolved:true`
returns everything. The `includeResolved` param is added to both tool
registrations (MCP index.ts + in-app ai-chat-tools.service.ts); `DocmostClientLike`
signature updated. Server `findPageComments` is NOT touched — the web UI's tabs
depend on the full feed; filtering is only at the mcp-client level. All internal
call sites (export_page_markdown / checkNewComments / transformPage) updated to
`.items` with `includeResolved:true` to keep their full-feed behavior.
The comment model is assumed FLAT (a reply's parentCommentId points at the
thread root) — documented in the filter; a future reply-of-reply model would
need a root-walk there.
Tests: resolved-comment-anchors.test.ts (6 — anchor dropped with flag / kept
without, for BOTH emitters; active always kept); list-comments-resolved.test.mjs
(4 — resolved thread+reply hidden + counter; includeResolved:true returns all;
an ACTIVE thread with a RESOLVED reply is NOT hidden).
package vitest: 664 passed; tsc clean. mcp: node --test 458 passed; tsc clean.
apps/server + git-sync: tsc clean (converter option default-off).
NOTE: based on feat/293-B (#293/#326 STEP 5) — the converter lives in the
package; this PR is stacked on #333 and its base retargets to develop once #333
merges. mcp/build is gitignored (not committed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit of all 41 tool descriptions against the actual implementation found
factually wrong or misleading texts:
- list_comments claimed '(paginated)' — it takes only pageId and returns ALL
comments in one call (internal pagination); now also states that RESOLVED
threads are included and how to filter them. In-app twin synced.
- search claimed the limit default is 'applied by the client' — the client
deliberately omits it so the SERVER applies its default.
- create_page's '(automatically moves it to the correct hierarchy)' said
nothing useful — now documents parentPageId nesting semantics; move_page
drops the stale 'essential for organizing pages created via create_page'.
- share_page now warns the page becomes accessible to ANYONE with the URL.
- get_page (both transports) now explains inline <span data-comment-id> tags
are comment anchors (incl. resolved) — markup, not page text.
- patch_node/delete_node/insert_node pointed only at the expensive page-JSON
view for block ids — now route through the cheap page outline first.
- docmost_transform marks 'Примечания переводчика' as the DEFAULT
notesHeading, overridable for non-Russian pages.
Checks: @docmost/mcp tests 450/450 (incl. the server-instructions guard);
server ai-chat-tools spec 20/20; mcp build/ artifacts rebuilt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- editorial roles (ru/en): proofreader and line editor attach suggestedText
replacements to targeted fixes; fact-checker ALWAYS attaches the ready
correction for [Incorrect] verdicts; structural editor and narrator get a
light-touch rule for in-place rewordings; role versions bumped and the
content-hash lock refreshed
- MCP SERVER_INSTRUCTIONS: route 'propose a concrete text fix for one-click
human approval' to create_comment with suggestedText (unique-selection
reminder); build/ artifacts rebuilt
- AI-chat SAFETY_FRAMEWORK: mention the comment-suggestion capability so the
default assistant offers ready fixes instead of only describing changes
Checks: catalog check.mjs OK; @docmost/mcp tests 448/448; server
ai-chat.prompt spec 28/28.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agents can attach a suggested replacement when creating an inline comment, via
both the MCP create_comment tool and the AI-chat createComment tool.
Because applying a suggestion edits the EXACT anchored text, an ambiguous anchor
would let Apply corrupt the wrong occurrence. So when suggestedText is set the
selection must occur EXACTLY ONCE:
- new countAnchorMatches(doc, selection) counts occurrences across all blocks
(same normalization/traversal as canAnchorInDoc), counting occurrences (2 in
one block => 2) — stricter than block-count, never under-counting distinct
occurrences (false-unique is the dangerous direction).
- client.createComment gains suggestedText: a pre-check (getPageJson +
countAnchorMatches: 0 => not-found, >=2 => ambiguity error) before create, and
an AUTHORITATIVE live check inside the anchoring mutation that recomputes on the
live doc and, if != 1, aborts and rolls back the just-created comment (reusing
the existing safeDeleteComment "anchor not found" path). Ordinary comments keep
first-occurrence behavior unchanged.
- suggestedText is rejected on a reply or without selection in all three layers
(MCP handler, MCP client, AI-chat tool), mirroring the server DTO/service.
- filterComment surfaces suggestedText/suggestionAppliedAt/suggestionAppliedById.
- DocmostClientLike.createComment signature updated. MCP build/ rebuilt.
Tests: countAnchorMatches (0/1/N, within/across/nested block, span nodes,
quote normalization); createComment (ambiguous refused pre-create, reply and
no-selection rejected, unique succeeds and forwards suggestedText, filterComment
surfaces it); ai-chat schema accepts suggestedText. MCP 443 pass; ai-chat 601 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/ai-chat/bound-chat 500'd with Postgres 22P02 because the client
sends a page slugId (10-char nanoid) in the request `pageId` field, which the
server passed straight into the UUID `page_id` column. The chat-to-document
binding silently broke (client fail-softs to a new chat) and every slug-URL
page open logged a 500.
Fix: resolve the incoming id to a real page UUID on the server. PageRepo.findById
already accepts both a uuid and a slugId (isValidUUID→slugId fallback), so
boundChat now resolves the page first, guards it against a foreign/unknown
workspace (returns {chatId:null} before any chat lookup — no cross-workspace
probe), and looks up the latest chat by the resolved page.id (real uuid).
Client: renamed the local pageId→slugId for clarity (the value is a slugId);
the wire body key stays `pageId` so the DTO is unchanged. DTO left @IsString()
(a @IsUUID() would only turn the 500 into a 400 and still break binding).
Test: bound-chat spec asserts a slugId resolves and findLatestByPage is called
with the real uuid; a foreign-workspace page → {chatId:null} without a chat
lookup (no leak); an unknown id → {chatId:null}, no throw.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The same tool metadata (zod schema + model-facing description) was hand-duplicated
between the standalone MCP server and the in-app AI-chat agent, so every tweak had to
land in two places and copies drifted (a materialized parity bug). The shared
transport-agnostic registry (packages/mcp/src/tool-specs.ts) already de-duplicates 14
tools; this migrates two more genuinely-identical ones — patch_node/patchNode and
insert_node/insertNode. The canonical description is a strict SUPERSET of both originals
(keeps MCP's "without resending the whole document" + table-structure/anchor guidance
AND the in-app "reversible via page history" / "exactly one of anchorNodeId or
anchorText" framing — no model-facing guidance dropped); the schema is identical (the
in-app side just gains MCP's .min(1) on ids, a safe tightening). Each transport keeps its
own execute/auth wrapper, and the in-app parseNodeArg node-arg normalization is unchanged.
The three table tools are intentionally NOT merged (a real param-name divergence:
table vs tableRef) — documented on both sides. Other per-transport divergences
(search/share/create_comment/transform/list_pages) are left separate with a short comment
explaining why (the issue asked to flag these as intentional). DocmostClientLike stays a
hand-mirror (the ESM/CJS boundary blocks a compile-time type import; a runtime drift-guard
already pins it). Also fixes a latent contract-spec bug: derive `required` from
`instanceof z.ZodOptional` (matches the emitted JSON schema) instead of `isOptional()`,
which wrongly reported z.any() fields as optional.
Partially addresses #294.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: pc.title (untrusted cross-user page title) was interpolated raw into the
markdown export heading. Reusing escapeAttr alone (the prompt sink's XML-attribute
sanitizer, strips < > ") is insufficient here because the sink is MARKDOWN: link
/image syntax survives, so a title like  or [phish](http://evil)
injects a remote image / clickable link into the downloaded .md disguised as a
trusted system annotation. Add markdownHeadingSafe() = escapeAttr() + backslash-
escape [ and ] (disables both [text](url) and ; a bare (url) is inert).
F2: cover the title branch — a title that collapses to empty via escapeAttr falls
to the bare heading (no ("")), and a link/image-injection title is neutralized
(non-vacuous vs the escapeAttr-only version).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>