Commit Graph

879 Commits

Author SHA1 Message Date
agent_coder 65da62c382 fix(#370): ребейз на develop + правки ревью agent_vscode (раунд 3)
Ребейз ветки на текущий gitea/develop (устраняет mergeable:false).
Конфликты разрешены вручную:
- editor-atoms.ts: сохранён type-only импорт Editor (сплит-код с develop),
  добавлен import type HocuspocusProvider из #370.
- collaboration/constants.ts: оставлен EMBED_DEBOUNCE_MS (embed-дебаунс develop),
  убраны более не используемые HISTORY_* (их единственный потребитель — старая
  эвристика computeHistoryJob — удалён в #370), добавлены idle-константы и
  PageHistoryKind.
- persistence.extension.ts: 3-way смёржены метрики/#348/#402 develop поверх
  idle-конвейера #370; computeHistoryJob остаётся idle-версией без остатков.

Миграция переименована 20260705T120000 -> 20260707T120000 (класс #361):
таймстамп был занят perf-indexes на develop; новый строго позже свежайшей
на develop (20260706T120000-search-lookup-trgm). Содержимое не менялось,
внешних ссылок на имя файла нет (Kysely находит по директории).

Документирование (findings 1-2):
- remove-vs-active гонка в idiome remove()->add() enqueuePageHistory: окно,
  где отложенная job уходит в active между remove (проглатывается на active) и
  add (BullMQ отбрасывает add с существующим jobId); ограничено и
  самовосстанавливается (следующий store перевзводит), кроме худшего случая —
  гонящий store был ПОСЛЕДНИМ в сессии: хвостовые правки без trailing-снапшота
  до следующей правки. Явно указано, почему нельзя «унифицировать» с соседним
  embed-дебаунсом (стабильный jobId, без remove).
- допущение single-process у Map idleBurstStart: в памяти процесса, рестарт
  collab теряет метки начала всплеска -> непрерывный всплеск через рестарт может
  ждать до 2x cap. Ограничено и безопасно.

Тест (finding 3): интеграционный тест idle-конвейера против реального BullMQ
(короткие интервалы через jest.mock констант): непрерывный всплеск в неск. cap
-> периодические idle-снапшоты не реже cap и не по одному на store;
прерывистый всплеск -> ровно один trailing-снапшот. Жёсткий teardown
(force-close + settle), чтобы фоновый BullMQ не влиял на соседние suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:18:41 +03:00
agent_coder 8bd883c998 fix(#370): thread trx into addPageWatchers (F7 self-deadlock) + restore contributors on commit-failure (F8) + assert the lock (F9) (review round 2)
The round-1 F3 fix (wrapping the processor's find+save in a locked tx) itself
introduced two regressions:

F7 [CRITICAL] addPageWatchers ran WITHOUT trx inside the tx holding FOR UPDATE on
pages[pageId]. The watcher insert's FK check takes FOR KEY SHARE on the same row,
but on a DIFFERENT pool connection — a true self-deadlock (our tx connection sits
idle-in-transaction awaiting the JS await, the insert connection blocks on the
lock). Now passes trx (addPageWatchers already accepts it and routes it through
insertMany), so the FK lock is taken on the connection that already holds FOR
UPDATE — no self-conflict.

F8 [WARNING] popContributors is a destructive Redis SPOP; the inner catch only
restores on a throw INSIDE the callback. A COMMIT failure throws OUTSIDE it,
rolling the snapshot back while the pop is gone → a retry writes an unattributed
version. Now tracks the popped set and restores it in an outer catch (idempotent
SADD), leaving BullMQ to retry with attribution intact.

F9 [WARNING] The spec asserted saveHistory args with a loosened objectContaining
that stopped verifying trx, and never pinned withLock/trx on findById or the trx
on addPageWatchers — which is exactly why F7 slipped. Restored the exact
saveHistory(trx) assertion and added findById({withLock,trx}) + addPageWatchers
trx assertions (the latter would have caught F7), plus a commit-failure test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:46:45 +03:00
agent_coder f7a91eb706 fix(#370): ES2021-safe spec, source-specific idle ceiling, processor lock, tested index mapping (review round 1)
F1 [BLOCKER] persistence-store.spec used Array.prototype.at(-1) (ES2022) but the
server targets ES2021, so server tsc failed (TS2550) and ts-jest could not
compile the suite — 22 core manual-save/idle/boundary tests silently did not run
in CI. Replaced with [length - 1] index access.

F2 [WARNING] The idle burst-reset used a hardcoded IDLE_MAX_WAIT_USER for both
tiers, but computeHistoryJob's ceiling is source-specific. On a continuously
agent-edited page the burst marker stayed stale for 5..10m, forcing delay=0 on
every store and writing one idle row per store — the exact per-store bloat the
debounce prevents. The reset now uses the same source-specific max-wait.

F3 [WARNING] The processor did an unlocked findPageLastHistory -> saveHistory,
which TOCTOU-races a concurrent manual-save (that runs under a page-row lock),
producing two page_history rows with identical content (one idle, one manual) and
defeating promote-not-dup. The snapshot decision is now wrapped in executeTx with
the same page-row lock, so the second writer observes the first's committed row
and the isDeepStrictEqual gate collapses the duplicate.

F4 [WARNING] The risky client filtered-index -> full-list mapping had no tests.
Extracted it to a pure resolvePrevSnapshotId(fullItems, id) helper (diff/restore
baseline against the true previous snapshot in the FULL list, never the previous
visible version) and unit-tested it; removed the now-vestigial index threading.

F5/F6 [low] Renamed the misleading ceiling test + fixed its comment; added a
CHANGELOG entry for the user-facing versioning feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:46:45 +03:00
agent_coder 9f2d03ce6b feat(#370): page-history intentionality tiers — kind column + intentional/idle/boundary triggers (PR-1 core)
PR-1 'core' of #370: introduces page_history.kind ('manual'|'agent'|'idle'|
'boundary'; legacy null = autosave) and rebuilds the snapshot triggers around a
three-tier intentionality model. Draft durability (pages/ydoc hocuspocus
autosave) is unchanged; only the frequency and labelling of history points change.

- Migration 20260705T120000: page_history.kind nullable varchar(20), no default.
- Manual Save: one stateless 'save-version' path for human AND agent; kind is
  derived SERVER-SIDE from the signed context.actor (never the payload), readOnly
  connections rejected, the fresh ydoc runs through the existing store path (no
  REST race), then broadcasts version.saved.
- Idle-flush: trailing debounce (one BullMQ job per page, remove-then-readd) with
  IDLE_INTERVAL_USER=60m / AGENT=15m AND a max-wait ceiling
  (IDLE_MAX_WAIT_USER=10m / AGENT=5m) so a continuous editing session can't starve
  the autosnapshot (review round-1 WARNING).
- Boundary: generalized from the user→agent special-case to ANY lastUpdatedSource
  transition (user↔agent↔git), same isDeepStrictEqual gate — covers git-sync free.
- Removed the agent delay=0 fast path and the old HISTORY_FAST_* constants; the
  agent joins the common idle pipeline.
- Promote-not-dup: a manual save on unchanged content promotes the latest
  autosave's kind in place (or no-ops if already manual) instead of duplicating a
  heavy content row.
- Client: mod+S hotkey + menu button (hidden when readOnly), history-panel kind
  badges, dimmed autosaves, a 'versions only' filter (indices map to the full
  list so diff/restore still target the true previous snapshot), live refresh on
  version.saved.

Internal review: APPROVE-with-suggestions; the round-1 WARNING (idle starvation)
is fixed here via the max-wait ceiling, and the generalized-boundary + ceiling
behaviours are pinned with new tests (115 collab/repo specs green, server tsc 0).

Deferred to later PRs: shares.published_mode (PR-2), the save_page_version MCP
tool + role prompts (PR-3), actor='git' wiring into #359 (PR-4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:46:45 +03:00
vvzvlad fe5bd159c4 Merge pull request 'refactor(client): вставка markdown через канонический пакет + удаление md-слоя editor-ext (#347)' (#498) from refactor/347-client-md-paste into develop
Reviewed-on: #498
2026-07-11 04:33:37 +03:00
agent_coder 1d89cc2058 fix(client): ревью #347 — Reasoning-panel отступы, диагностика вставки, тест guard'а, комменты, доки (#347, ревью)
Правки по 5 находкам ревью #498.

F1 (регрессия отступов списков в Reasoning-панели): добавлен `.reasoningText li p
{margin:0}` (зеркало существующего `.markdown li p`). Reasoning рендерит через тот
же renderChatMarkdown (теперь всегда <li><p>…</p></li>), но под .reasoningText, где
`.reasoningText p{margin:0 0 4px}` давал 4px на пункт. Обе поверхности покрыты.

F2 (глухой catch): `.catch` вставки был `()=>{}` → теперь `(err)=>console.error(
"markdown paste conversion failed, inserting raw text", err)` — тихая деградация в
raw-текст больше не невидима (покрывает и конвертер, и тело success-.then, напр.
PMNode.fromJSON при дрейфе схемы).

F3 (нет теста doc-changed guard): +3 теста в markdown-clipboard.paste.test.ts:
success-ветка при mid-flight изменении дока → вставка в живую selection (маркер
цел, без клоббера/throw); fail-open ветка при mid-flight + провале конверсии →
raw-текст в живую selection без RangeError; две вставки в полёте → инвариант «ни
один payload не потерян».

F4 (устаревшие комменты): исправлены ссылки на удалённый md-слой в markdown-
clipboard.ts, footnote-sync/util(+test), docmost-schema, foreign-markdown,
footnote-canonicalize → на @docmost/prosemirror-markdown / локальные символы.

F5 (внешние доки): AGENTS.md (apps/client как потребитель через browser-entry,
jsdom только в Node, удалён marked/turndown-слой); prosemirror-markdown/README
(секция Node vs browser entry, markdownToProseMirrorSync); CHANGELOG.

Тесты: client paste+canonicalize+ai-chat 61; pmd 744; editor-ext 196; клиентская
сборка успешна, grep бандла на JSDOM/parse5/happy-dom/turndown — 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 03:02:08 +03:00
agent_coder 70a9e2a9cb fix(mcp): оживить счётчики кэша getPage + тест «хит пропускает конверсию» (#479, ревью)
Правки по ревью #480.

F1 (мёртвые метрики): счётчики mcp_getpage_cache_hits_total/_misses_total
эмитились через onMetricFn, но серверный синк mcp.service.ts диспатчил по имени
через if/else-if БЕЗ default и знал только 2 имени → мои дропались молча; в
metrics.registry их вообще не было. Починка по существующему 3-частному паттерну
(как collab_connect_timeouts_total): имена-константы в metrics.constants.ts; два
Counter'а + incGetPageCacheHit/Miss в metrics.registry.ts; два else-if в
mcp.service.ts, роутящие ровно эти имена (существующие 2 ветки не тронуты).
Проверено end-to-end: скрейп prom-реестра показывает hits=2/misses=1 после
прогона роутинга.

F2 (нет теста на пропуск конверсии): добавлен overridable seam
convertPageMarkdown в read.ts (идиома проекта для юнит-тестируемости ESM-импортов);
getPage miss-ветка идёт через него. Тест мокает convertPageMarkdown и ассертит
callCount===1 через MISS→HIT одной страницы (конверсия один раз на промахе, ноль
на хите). Мутационно доказан: пропатчил hit-ветку на повторную конверсию → тест
покраснел (callCount=2), откатил → зелёный.

mcp node --test 814/814 (+1); pmd+mcp tsc чисто; серверные metrics-файлы
компилируются изолированно, runtime-тест счётчиков зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 02:59:47 +03:00
agent_coder 5d8083f8ff refactor(client): вставка markdown через канонический @docmost/prosemirror-markdown + удаление md-слоя editor-ext (#347)
Третий шаг #345: клиентская вставка markdown переезжает с marked-слоя editor-ext
(не знал канона — ^[…], <!--img {…}-->, <!--subpages--> при вставке не
распознавались) на канонический пакет. По завершении слой удалён целиком. closes #347

- Browser-entry пакета (инъекция DOM-парсера): jsdom только в Node-пути.
  dom-parser.ts — 2 слота инъекции (HtmlDocumentParser для HTML→Document,
  GenerateJsonFn для @tiptap/html), без импорта DOM. dom-parser.node.ts
  регистрирует jsdom + @tiptap/html/server; dom-parser.browser.ts — нативный
  DOMParser + @tiptap/html (browser), экспонирован через exports-условие
  "browser" + сабпас "./browser". markdown-to-prosemirror.ts: убраны статический
  импорт jsdom и module-level global.window-шим. Клиент ВСЕГДА импортирует явный
  сабпас /browser — не полагается на порядок условий. Node-потребители (mcp/
  server) идут по "." → default → index.js → jsdom, не затронуты.
- markdown-clipboard.ts: конвертация через browser-entry (markdownToProseMirror
  → PM-JSON → HTML через живую схему редактора DOMSerializer → НЕИЗМЕНЁННЫЙ
  downstream-шов normalizeTableColumnWidths→parseSlice→canonicalizePastedFootnotes
  →dispatch). Эвристики/fragment-insertion не тронуты. Конвертер async → handle
  Paste захватывает диапазон, забирает событие, диспатчит на резолве; и success,
  и fail-open ветки защищены guard'ом doc!==startDoc (не диспатчить по устаревшему
  диапазону). clipboardTextSerializer (copy PM→md) — через convertProseMirror
  ToMarkdown.
- Удалён packages/editor-ext/src/lib/markdown/ целиком (+ marked из package.json).
  Мигрированы ВСЕ потребители markdownToHtml/htmlToMarkdown: ai-chat/utils/
  markdown.ts (→ новый markdownToProseMirrorSync + DOMSerializer), use-generate-
  page-title.ts / page-header-menu.tsx (→ convertProseMirrorToMarkdown(getJSON)),
  серверный spec. Grep: осиротевших импортов нет, editor-ext = только схема/
  расширения. Turndown ушёл из бандла (был в старом htmlToMarkdown).
- AI-чат теперь рендерит markdown через схему редактора (li в <p>); добавлен
  .markdown li p{margin:0} (CSS-модуль, скоуп только чата) — визуально плотно.

Проверка: pmd tsc + vitest 744; client build УСПЕШЕН, grep бандла на
JSDOM/parse5/happy-dom/turndown — 0 (утечки нет); клиентский suite + paste-тесты
зелёные (34); editor-ext 196; node-потребители (mcp/server/git-sync) зелёные.
Юнит-тесты: dual-path parity (jsdom==DOMParser), канон-формы == серверный импорт,
негативы ($5/==/[^1] не корёжатся), async-paste (claim→convert→dispatch, fail-open).

Ручная paste-QA полного клиентского round-trip (footnote/callout/math/image-comment;
вставка из VSCode/Obsidian/GitHub в список/таблицу/callout) и Docker-сборка клиента
(#333-класс) — за пределами автостенда, оставлено на ручную проверку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 02:11:19 +03:00
agent_vscode e670f7498a fix(ai-chat): sync CORE_TOOL_KEYS with #443 core tools — restore mcp-server-parity
The #443 merge added getTree and getPageContext to the shared registry
(packages/mcp/src/tool-specs.ts) with tier 'core', but the server-side
authoritative core list CORE_TOOL_KEYS was never updated. Two guard tests
in tool-tiers.spec.ts failed on develop (CI job test / mcp-server-parity):
tier agreement SHARED_TOOL_SPECS <-> CORE_TOOL_SET, and the live-toolset
<-> deferred-catalog partition (both tools were live, non-core and absent
from the catalog).

- add 'getTree' and 'getPageContext' to CORE_TOOL_KEYS (kept core, not
  demoted: cheap single-call navigation tools; core listPages description
  itself points to getTree)
- update the CORE_TOOL_KEYS JSDoc accordingly
- pin the first tool-tiers spec to the new 17-entry list with membership
  assertions for both #443 tools

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:03:34 +03:00
agent_coder 2a951df096 feat(mcp): drawio стадия 3 — семантические тулы drawioFromGraph/FromMermaid/EditCells (#425)
Сырой XML остаётся escape-hatch, но для 90% случаев модель не видит ни координат,
ни style-строк — весь класс ошибок лейаута и иконок уходит by construction: эти
решения принимает сервер, а не LLM. Стоит на #443, переиспользует конвейер #423 и
shape-index/elkjs/линтер #424.

- drawioFromGraph(pageId, where, graph, direction?, preset?, layout?): граф
  узлов/групп/связей → резолв иконок (shape-index #424; неизвестная → generic по
  kind с подписью, не пустой квадрат), стили из пресета (kind→палитра),
  elkjs-layered с compound-группами, ассемблер XML (линтер-чистый by construction:
  зазоры >=150, прозрачные контейнеры, относительные координаты детей,
  cross-container рёбра parent=1, эскейп меток). Хинты pinned/sameLayerAs/layer и
  layout none/full/incremental — детерминированным post-pass'ом (ELK-констрейнты
  оказались ненадёжны). Пресеты default/dark/colorblind-safe (Okabe-Ito) — данные.
- drawioFromMermaid(pageId, where, mermaid): чистый парсер flowchart (без
  браузера/CLI) → graph → тот же конвейер. Формы/направление/пунктир=async/
  subgraph→группы/цепочки; не-flowchart отвергает внятно.
- drawioEditCells(pageId, node, operations, baseHash): ID-based add/update/delete,
  delete каскадит на детей контейнера и связанные рёбра; baseHash обязателен
  (optimistic lock как drawioUpdate); сентинелы 0/1 от delete защищены.

DoS-границы (LLM-вход): MAX_GRAPH_EDGES=1000/GROUPS=500 в validateGraph (узлы уже
500), mermaid MAX_CHARS=200k/LINES=20k/GROUPS=500, chain 500 — все с быстрым
throw ДО лейаута/ассемблера (ассемблер и маппер вне ELK-таймаута, иначе OOM
воркера). incremental сохраняет неперечисленные существующие ячейки (mergeExisting
Cells) — «добавь узел» не стирает ручную расстановку. sameLayerAs/layer после
снапа раскладываются по перпендикулярной оси с зазором >=150 → 0 quality-warnings;
pinned — точные пользовательские координаты (clamp>=0, могут дать warning, гарантия
«0 by construction» относится к авто-лейауту).

Регистрация: 3 shared-spec на оба хоста (camelCase, execute-in-spec, inlineBoth
Hosts не понадобился), DocmostClientLike/Method += 3, contract, routing-проза
(fromGraph→архитектуры/облака, fromMermaid→стандартные flowchart, raw xml→экзотика).

Тесты: mcp node --test 782/782 (57 новых) — приёмка (15+ узлов/2 вложенные группы/
AWS-иконки→0 lint/0 warnings/иконки резолвятся, hints, incremental без сдвига,
edit_cells update/cascade/stale-baseHash, снапшоты пресетов + colorblind-safe,
mermaid ветвление+subgraph) + регрессии на DoS-границы. tsc чисто; server jest
(contract + ai-chat) 290/290. closes #425.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:16:50 +03:00
agent_coder 5dc7a2703f feat(mcp): getPageContext — «где я / что вокруг» по pageId одним вызовом (#443, часть 3/3)
Финальная из трёх частей #443. Есть pageId (из поиска, из ссылки) — нужно понять
местоположение и окружение. Раньше это get_page + полное дерево. Только чтение,
только метаданные (без контента). closes #443.

- client.ts getPageContext(pageId): resolvePageId (slugId→uuid, для uuid без
  round-trip) → POST /pages/breadcrumbs + listSidebarPages(spaceId, uuid). Два
  запроса для uuid-входа (spaceId берётся из ответа breadcrumbs), +1 для slugId.
  Выход {page:{pageId,title,spaceId}, breadcrumbs:[{pageId,title}] (root→parent;
  [] для корня), children:[{pageId,title,hasChildren}]}.
- Порядок breadcrumbs выверен по серверному источнику (getPageBreadCrumbs: CTE
  сидится на childPageId, идёт вверх, .reverse() → root→page; страница —
  ПОСЛЕДНИЙ элемент). page = chain[last], breadcrumbs = chain.slice(0,-1); корень
  → []. Проекция явная — наружу только pageId (UUID), slugId/icon/position не
  утекают. Пустой chain / 404 / 403 → явная ошибка, не пустой объект.
- children — прямые дети через listSidebarPages (cursor-пагинация #442/#451,
  страница с >20 детьми отдаёт всех без дублей, порядок по position, hasChildren).
- Общий реестр: getPageContext на ОБОИХ хостах через цикл спеков (mcpName===
  inAppKey==="getPageContext", camelCase); DocmostClientLike/DocmostClientMethod
  += it; compile-time contract; TOOL_FAMILY READ + routing-проза.

Тесты: get-page-context.test.mjs +6 (3-й уровень split, no-slugId-leak + 2
запроса, корень→[], slugId-resolve, >20 детей без cap/дублей, плохой id→ошибка,
пустой chain→throw), tool-specs +1. mcp node --test 725/725, tsc чисто; server
jest (contract + ai-chat) 265/265.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:19:25 +03:00
agent_coder bfb4c8d8d0 feat(mcp): getTree — иерархия пространства/поддерева одним вызовом (#443, часть 2/3)
Раньше единственный способ получить дерево — listPages tree:true: BFS по
sidebar-эндпоинту, десятки-сотни HTTP-вызовов и молчаливая потеря страниц
(#442). Бэкенд форка уже отдаёт всё одним POST /pages/tree (getSidebarPagesTree,
merged #442/#451), так что это MCP-сторона + общий реестр. Только чтение.

- lib/tree.ts: buildPageTree(nodes) аддитивно расширен до buildPageTree(nodes,
  options?) с {shape?: "lean"|"getTree"; maxDepth?}. Дефолт/{} — байт-идентичный
  lean {id,slugId,title,children?} (существующие вызыватели listPages tree:true
  и subtree-BFS не тронуты, есть регрессионный тест на точный набор полей).
  shape:"getTree" проецирует {pageId, title, children?, hasChildren?} (id→pageId;
  slugId/icon/position/parentPageId не утекают ни на одной глубине).
- client.ts: getTree(spaceId, rootPageId?, maxDepth?) — тот же код-путь, что
  listPages tree:true: один enumerateSpacePages(spaceId, rootPageId) (единичный
  /pages/tree + cursor-BFS фолбэк для stock upstream) → buildPageTree(pages,
  {shape:"getTree", maxDepth}). listPages tree:true помечен DEPRECATED в JSDoc
  (BFS-фолбэк на месте, поведение не тронуто).
- maxDepth/hasChildren: полное дерево строится, потом обрезается. Корни = глубина
  1; maxDepth:1 → только корни; hasChildren:true ТОЛЬКО на срезанном узле с
  серверным hasChildren (source of truth), опущен у листьев и раскрытых узлов.
  Схема тула min(1) отбраковывает 0/отрицательные.
- tool-specs.ts: shared-спек getTree (оба хоста через цикл реестра),
  DocmostClientLike Pick += getTree, listPages desc/catalogLine — про депрекацию.
  server-instructions.ts: getTree:"READ" + routing-проза. Loader
  DocmostClientMethod += getTree, compile-time client-call contract += getTree.

Циклы/self-ref безопасны: project рекурсирует только от корней, циклический
компонент недостижим из корней (нет бесконечной рекурсии). Orphan (родитель
отфильтрован пермишенами) всплывает как корень.

Тесты: tree.test.mjs +9 (nesting+порядок+no-leak, maxDepth:1/2 + hasChildren,
orphan→root, seeded rootPageId, ≤0/нефинитный = без среза, lean-байт-идентичность),
tool-specs.test.mjs (схема getTree + депрекация listPages). mcp node --test
718/718, tsc чисто; server jest (shared-tool-specs.contract + ai-chat) 263/263.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:58:32 +03:00
agent_coder f794ac6d6c fix(search): оживить trgm-индекс — убрать coalesce из LIKE-предикатов (#443, ревью)
Ревьюер поймал EXPLAIN'ом: lookup-query фильтровал по
LOWER(f_unaccent(coalesce(col,''))), а GIN-trgm-индексы — на coalesce-free
LOWER(f_unaccent(col)). Postgres берёт функциональный индекс только при точном
совпадении выражения, так что обёртка coalesce отключала индекс → Seq Scan по
pages на КАЖДЫЙ lookup (MCP-клиент всегда шлёт substring:true). Зелёный
int-гейт это не ловил (проверял корректность на крошечном датасете, не
использование индекса).

- search.service.ts: убрал coalesce из двух index-driving LIKE-предикатов
  (title + text_content). Семантически эквивалентно: NULL LIKE '%q%' = NULL
  (falsy), NULL-страница просто не матчится — как пустая строка не матчит %q%.
  SELECT/ORDER BY оставлены с coalesce (индекс не выбирают, Node гардит NULL).
- Миграция: убран избыточный ре-ассерт idx_pages_title_trgm — его создаёт #348
  на том же coalesce-free выражении, теперь title-предикат его использует.
  idx_pages_text_content_trgm без изменений (уже coalesce-free), поправлены
  вводящие в заблуждение комментарии. down() дропает только text-индекс.
- Новый тест search-lookup-explain.int-spec.ts (3): title→idx_pages_title_trgm,
  text→idx_pages_text_content_trgm (оба ассертят отсутствие Seq Scan on pages),
  + негативный контроль (coalesce-обёртка не может использовать индекс) против
  тихой ре-регрессии. Дискриминатор enable_seqscan=off.
- CHANGELOG: две записи (opt-in substring lookup mode + смена shape MCP search).

EXPLAIN на реальном PG: обе fixed-ветки → Bitmap Index Scan on trgm; buggy
coalesce → Seq Scan (Disabled:true). Гейт: server jest src/core/search 27/27
(16 исходных int без изменений + 3 EXPLAIN + 8 unit), mcp node --test 708/708,
tsc чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:57:16 +03:00
agent_coder e4e788f151 feat(search): агентский lookup-режим — substring по точкам/дефисам/цифрам, path, snippet, scope (#443, часть 1/3)
MCP-сервером пользуются LLM-агенты; основной паттерн — lookup: найти страницу по
обрывку технической строки и сразу понять, где она лежит и что в ней. Раньше на
такой вопрос уходило 3–5 вызовов. Эта часть закрывает `search` (часть 1 из 3;
get_tree и get_page_context — следующими PR стопкой). Только чтение.

Серверная часть — opt-in, веб-UI байт-в-байт неизменен:
- SearchDTO: опциональные substring/parentPageId/titleOnly (default-off; без
  флагов путь FTS не тронут — guard `if (substring) return searchPageLookup`).
- searchPageLookup: один скан pages, WHERE = title LIKE '%q%' OR (если не
  titleOnly) text LIKE '%q%' OR (если tsquery непустой) tsv @@ ... . LIKE-
  метасимволы %/_/\ экранируются (escapeLikePattern, ESCAPE '\') — `%`/`_` не
  матчат всё; substring-ветка работает даже при пустом tsquery (кейс 10.0.12).
- Ранжирование тирами (TITLE_EXACT > TITLE_SUBSTRING > TEXT), вторичный сигнал
  ts_rank / позиция; score∈(0,1] только для сортировки одной выдачи (формула в
  комментарии). 200-cap упорядочен по SQL-прокси тира ДО среза (иначе Postgres
  отдаёт произвольные 200 и сильный хит мог выпасть). Пермишен-фильтр к
  merged-набору ДО limit. path — одна рекурсивная CTE на все хиты (не N+1).
- snippet оконный в SQL (~500 символов вокруг первого совпадения). Позиция и
  срез в ОДНОМ пространстве LOWER(f_unaccent(...)) — f_unaccent не length-
  preserving (ß→ss, лигатуры, …→...), иначе окно смещалось/пустело. titleOnly →
  пустой snippet. Компромисс задокументирован.
- Миграция: GIN gin_trgm_ops по LOWER(f_unaccent(text_content)); title-trgm
  индекс #348 переиспользован (IF NOT EXISTS), down() дропает только новый.

MCP: схема search (spaceId/parentPageId/titleOnly/limit 1–50, default 10),
client.search прокидывает substring:true, filterSearchResult → {pageId, title,
path, snippet, score}. Инвариант: наружу только pageId (UUID), slugId/id
никогда. Комментарии про намеренное расхождение с in-app hybrid-RRF (не тронут)
и про деградацию на stock-upstream/Typesense (substring→plain FTS, без path/
snippet).

Проверка на реальном Postgres: server integration 16/16 (вся acceptance-таблица
#443 + регрессии на смещение snippet и cap-200), server unit 27/27, mcp
node --test 708/708, tsc чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:34:53 +03:00
agent_coder d269bd9efe feat(mcp): markdown — формат по умолчанию для блочных getNode/patchNode/insertNode (#413)
Канонический конвертер (#293/#345/#351) зрел для блочного уровня: markdown
становится дефолтом чтения/записи ОДНОГО блока, PM JSON — опция «для тонких
работ». Новых тулов нет, поверхность не растёт. Имена уже camelCase (стоит на #412).

- getNode(pageId, nodeId, format='markdown'): дефолт markdown — обёртка
  {type:doc,content:[node]} → convertProseMirrorToMarkdown, comment-якоря
  (ВКЛЮЧАЯ resolved) сохранены (это чтение под редактирование, не getPage).
  format:'json' — сырой сабтри. Авто-фолбэк не-топ-левел типов (tableRow/Cell/
  Header по #<index>) в JSON через canBeDocChild = docmostSchema.nodes.doc.
  contentMatch.matchType (не рукописный список); поле format на каждом ответе.
- patchNode/insertNode: XOR-вход {markdown?|node?} (оба optional в схеме, XOR
  на рантайме). markdown → импорт фрагмента → 1→N сплайс: первый блок наследует
  id цели, остальные свежие; dry replaceNodeById для #159-ambiguity ДО сплайса;
  соседние блоки byte-identical. Guard findUnrepresentableTableAttrs: цель со
  span/colwidth/backgroundColor → отказ с указанием на table-тулы/node-JSON.
  insertNode — insertNodesRelative (N блоков по порядку); голый tableRow/Cell
  JSON-only.
- Сноски: ^[...] во фрагменте → канон-импортёр; importMarkdownFragment делит
  блоки от footnotesList, РЕМАПИТ id сносок фрагмента в свежие uuid (fn-1
  фрагмента не коллизит с fn-1 страницы), mergeFootnoteDefinitions добавляет
  через ту же машинерию appendDefinition→normalizeAndMergeFootnotes→
  canonicalizeFootnotes, что insertFootnote. Сырые JSON-пути не тронуты.
- node-ops: replaceNodeByIdWithMany/insertNodesRelative (сплайс массива) в
  prosemirror-markdown/node-ops (канон после #414) + barrel.
- ROUTING_PROSE (READ/EDIT), compile-time client-call contract, CHANGELOG
  (getNode default→markdown, breaking для внешних клиентов, в окне #411/#412).

Тесты: сходимость (patchNode(markdown) блок docsCanonicallyEqual полному
импорту — нет «второго канона»); id-нить 1→N (первый наследует, остальные
свежие, соседи byte-identical); XOR; getNode markdown/json/non-top-level-fallback;
сохранение comment-якорей (active+resolved); ^[...]→хвостовой список+перенумерация;
guard. mcp node --test 697/697; pmd vitest 736; tsc чисто; server jest 273.
Третий линк breaking-окна, стоит на #412 (#411→#412→ЭТОТ→#415).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:10:38 +03:00
agent_coder 7cb3199d09 refactor(mcp)!: BREAKING — все имена MCP-тулов snake_case → camelCase (унификация с in-app) (#412)
Один логический тул жил под двумя именами: внешний MCP snake_case
(edit_page_text), in-app camelCase (editPageText) — дублирование доков, путаница
при переносе промптов/скиллов, помеха шарингу спек (#294). Решение владельца:
единый camelCase везде, включая внешний MCP. После этого mcpName === inAppKey.

- tool-specs.ts: mcpName ВЫВЕДЕН из ключа спеки (mcpName == inAppKey) для всех
  43 shared-спек — раньше divergent snake, теперь равен ключу (проверено: mcpName
  читается только структурно — цикл регистрации, генератор <tool_inventory>,
  TOOL_FAMILY). +5 inline-регистраций (tableGet/updateComment/deleteComment/
  docmostTransform; search без изменений). Рантайм: 47 тулов, все camelCase, ноль
  подчёркиваний.
- Контракт-конвенция ИНВЕРТИРОВАНА: shared-tool-specs.contract.spec
  `mcpName === toSnake(inAppKey)` → `mcpName === inAppKey`; tool-specs.test
  и tool-inventory.test обновлены.
- ROUTING_PROSE/TOOL_FAMILY/INLINE_MCP_INVENTORY (server-instructions.ts) →
  camelCase (105 замен). ai-chat.prompt/guard уже на in-app camelCase-ключах —
  без изменений (guard прошёл). comment-signal EXCLUDED_TOOLS схлопнут с
  дублей snake+camel до camelCase.
- Некоторое неочевидное: assertUnambiguousMatch(op: "patch_node"|"delete_node")
  в prosemirror-markdown/node-ops — op интерполируется в model-facing ошибку;
  литерал-юнион + call-sites → "patchNode"|"deleteNode".
- Все snake-имена в описаниях/error-строках/комментах/тестах/доках → camelCase
  (whole-token, longest-match-first). CHANGELOG: BREAKING-таблица 46 строк +
  миграция (allowlists mcp__gitmost-*__get_node→__getNode, промпты/скиллы,
  .mcp.json, метрики по tool-label); релизится вместе с #411.
  Внутренние имена методов (PageService.updatePageContent и т.п.) НЕ тронуты —
  переименованы только ИМЕНА ТУЛОВ.

Гейт: mcp node --test 677/677; tsc -p apps/server чисто; jest ai-chat-tools.
service + shared-tool-specs.contract + tool-tiers + ai-chat.prompt +
comment-signal-inapp → 323. Второй линк breaking-окна (#411→ЭТОТ→#413→#415).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 19:10:38 +03:00
agent_coder 3a521ada4d refactor(tools): updatePageContent → updatePageMarkdown; внешний MCP: +updatePageMarkdown, −import_page_markdown (#411)
Поверхности записи «целым телом» были несимметричны: у in-app агента полная
замена тела markdown называлась updatePageContent (имя не про формат, тогда как
парный updatePageJson — про JSON), а у внешнего MCP голого plain-body-replace
не было вовсе (только import_page_markdown — на деле парсер round-trip к
export_page_markdown, не plain-replace). Пара должна быть updatePageMarkdown /
updatePageJson.

Пост-Фаза-1б архитектура (реестр + циклы по обоим хостам):
- новая shared-спека updatePageMarkdown (mcpName update_page_markdown, inAppKey
  updatePageMarkdown, tier как у updatePageJson) с execute (client, {pageId,
  content, title}) => client.updatePage(...) — тот же путь updatePageContentRealtime
  → markdownToProseMirrorCanonical, ^[...]-сноски парсятся. Реестровый цикл
  регистрирует её на ОБОИХ хостах автоматически. Добавлен 'updatePage' в
  Pick DocmostClientLike.
- import_page_markdown убран с внешнего MCP через inAppOnly:true у спеки
  importPageMarkdown — MCP-цикл и генератор инвентаря её пропускают, in-app
  агент сохраняет importPageMarkdown; спека и client-метод НЕ удалены.
- удалён inline in-app updatePageContent tool (теперь из реестра под inAppKey
  updatePageMarkdown) + его INLINE_TOOL_TIERS-энтри.
- ROUTING_PROSE: bulk-rewrite ссылается на update_page_markdown|update_page_json;
  убрано упоминание import_page_markdown; инвентарь генерируется из catalogLine.
- лейбл-мапы chat-markdown.util (en/ru), человекочитаемые метки не тронуты.
- НЕ тронуты одноимённые внутренности: PageService.updatePageContent,
  updatePageContentRealtime, collaboration.handler — переименовано только имя тула.

Тесты: updatePageMarkdown на обеих поверхностях с идентичной схемой, forward в
client.updatePage; import_page_markdown ОТСУТСТВУЕТ на MCP, присутствует in-app;
^[...]→сноски покрыт через collaboration.test. CHANGELOG BREAKING + миграция;
README/README.ru пакета обновлены. Гейт: mcp node --test 646/646, server jest
259, tsc чисто. Первый линк breaking-окна #416 (#411→#412→#413→#415).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 18:33:20 +03:00
agent_coder ddb37376a4 refactor(mcp): вписать drawio-тулы в реестровый цикл (Фаза 1б смержена) (#440)
Фаза 1б (#445/#446/#447/#448) влилась ПЕРЕД этим PR, поэтому явная проводка
drawio через registerShared/sharedTool конфликтовала с реестровыми циклами.
Фолд:
- drawioGet/Create/Update → канонический execute в спеке (это client-методы):
  execute возвращает сырой результат, цикл оборачивает jsonContent на MCP и
  отдаёт как есть in-app — байт-в-байт как старые тела, overrides не нужны.
  layout сохранён: добавлен в buildShape create/update + 5-м аргументом
  (layout as 'elk'|undefined) в обоих execute.
- drawioShapes/drawioGuide → остаются inline на ОБОИХ хостах. Гайд архитектора
  «execute в спеке с импортом searchShapes/getGuideSection в tool-specs.ts»
  оказался невозможен: drawio-shapes.ts использует import.meta.url, а
  tool-specs.ts тайпчекается из исходника под module:commonjs (contract-спека)
  → TS1343 на статический value-import, а import.meta не индиректится. Введён
  флаг спеки inlineBothHosts: спеки остаются в SHARED_TOOL_SPECS (contract
  пинит имя/описание/схему), но без execute; ОБА цикла их пропускают (добавлен
  симметричный guard в MCP-цикл), каждый хост регистрирует их inline через
  чистые хелперы — поведение байт-в-байт как до ребейза.
- docmost-client.loader: взята develop-форма DocmostClientLike = Pick<
  DocmostClient> (#446); ручное зеркало убрано, паритет layout наследуется из
  реальной сигнатуры client.
- ROUTING_PROSE: drawio intent-подсказки (shapes-first, guide, layout:elk);
  инвентарные строки убраны (генерируются из catalogLine).

Гейт: mcp node --test 674/674; server jest ai-chat-tools.service + contract
(211, все 5 drawio на обоих хостах, идентичная схема) + tool-tiers → 264;
tsc чисто; layout-passthrough тест 3/3. ELK DoS-кап и layout-фикс из round 3/4
сохранены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:34:54 +03:00
agent_coder dc58974b31 fix(ai-chat): пробросить layout:elk в in-app drawioCreate/drawioUpdate — паритет с MCP (ревью #440)
In-app хендлеры drawio_create/drawio_update деструктурировали args БЕЗ layout и
не передавали его клиенту 5-м аргументом → layout:"elk" (схема его принимает —
общий buildShape) ТИХО терялся, ELK-автолейаут работал только по MCP-хосту.
Корень: ручное зеркало DocmostClientLike (loader) отстало от реального client.ts
— у его drawioCreate/drawioUpdate не было параметра layout (то, что #446 чинит
деривацией типа, но #446 ещё не влит). Добавил layout?:'elk' в обе сигнатуры
зеркала + проброс в обоих хендлерах.

Тест (пропущенный зелёным гейтом пробел — не было теста на in-app passthrough):
in-app drawioCreate/drawioUpdate с layout:'elk' → фейк-клиент получает layout
5-м позиционным аргументом; omit-кейс → undefined. Мутационно: убрать проброс
в drawioCreate → layout-create-тест краснеет.

Гейт: mcp build чисто; tsc -p apps/server без новых ошибок; jest
ai-chat-tools.service (35) + shared-tool-specs.contract + tool-tiers +
comment-signal-inapp → 273 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:16:19 +03:00
agent_coder c917dcc3c1 fix(mcp): ограничить ELK-лейаут (кап узлов/рёбер + таймаут) — untrusted-граф DoS (ревью #440)
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>
2026-07-10 16:15:06 +03:00
agent_coder eddc3b5c33 feat(ai-chat): пробросить drawio_shapes/drawio_guide in-app — восстановить SHARED_TOOL_SPECS-паритет (#424)
Стадия-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>
2026-07-10 16:15:06 +03:00
vvzvlad a53b2f454e Merge pull request 'fix(delivery): immutable-кэш ассетов — отключить дефолтный cacheControl @fastify/static (#452)' (#455) from fix/452-immutable-cache-control into develop
Reviewed-on: #455
2026-07-10 16:03:03 +03:00
vvzvlad d219eb7525 Merge pull request 'fix(ai-chat): защита от петель агента — lockdown под тоггл + детектор деградации + бюджет шагов (#444)' (#454) from fix/444-agent-loop-guards into develop
Reviewed-on: #454
2026-07-10 16:02:48 +03:00
agent_coder d0f99052cf refactor(tools): генерировать инвентарь SERVER_INSTRUCTIONS из реестра + guard имён тулов в промпте (#448)
Финальный линк Фазы 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>
2026-07-10 09:21:28 +03:00
agent_coder 8c74659d91 refactor(tools): execute-маппинг в SHARED_TOOL_SPECS + автопроводка обоих хостов (#445)
Ядро Фазы 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>
2026-07-10 08:59:40 +03:00
agent_coder fe5b6ecd8c refactor(mcp): вывести DocmostClientLike/SharedToolSpec из реального типа клиента — убить ручные зеркала (#446)
Восстановленный отложенный долг #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>
2026-07-10 08:29:46 +03:00
agent_coder 2e6f1c3de5 fix(ci,mcp): REGISTRY_STAMP в билд-артефакте + кросс-пакетный CI — закрыть skew build/ vs src/ (#447)
Два структурных слепых пятна: (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 07:58:37 +03:00
agent_coder e24ddf6b3e test(ai-chat): покрыть safety-путь детектора деградации + границы (ревью #454)
Ревью: дизайн LGTM, но safety-фича недотестирована. Добавлено (только тесты,
прод-код не тронут):
- e2e-реакция детектора: streamText эмитит degenerate-чанки → union abortSignal
  срабатывает с 'Output degeneration detected' (отличимо от Stop) → onAbort
  пишет status:error + OUTPUT_DEGENERATION_ERROR + усечённый content, лиза MCP
  закрыта. Именно ДЕЙСТВИЕ на детект (детектит-но-не-действует = защиты нет);
- граница monochar-порога: hasPeriodicTail('x'×59)=false, ('x'×60)=true
  (мутация >=→> раньше выживала);
- empty-turn маркер (шаги исчерпаны + без текста → STEP_LIMIT_NO_ANSWER_MARKER;
  негативы на нормальный текст-ход и на исчерпание-с-текстом — гардят AND);
- различение degeneration-onAbort vs user-Stop (Stop → status:aborted, без
  error/усечения).

Мутационно: (a) >=→> роняет 60-границу; (b) нейтрализация onAbort-ветки роняет
reaction-тест; (c) нейтрализация маркера роняет empty-turn-тест. +7 тестов,
137 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:54:34 +03:00
agent_coder 15a9eba562 fix(delivery): immutable-кэш ассетов — отключить дефолтный cacheControl @fastify/static (#452)
Хэшированные ассеты отдавали 'cache-control: public, max-age=0' вместо
'public, max-age=31536000, immutable' → повторные заходы ревалидировали каждый
ассет (десятки 304 × мобильный RTT), главный выигрыш #346 не реализовывался.
Причина: у @fastify/static опция cacheControl:true по умолчанию пишет свой
Cache-Control (из maxAge, дефолт 0) ПОСЛЕ setHeaders-колбэка, затирая immutable-
заголовок из resolveStaticAssetHeaders. Фикс — cacheControl:false, колбэк
владеет заголовком. preCompressed не конфликтовал, потому баг был только в
заголовках.

Крайние случаи проверены: locales/vad/иконки получают только vary (без
cache-control → браузер ревалидирует по etag — ок); index.html отдаётся
отдельным wildcard-роутом со своим no-cache (не затронут); preCompressed .br
получает путь с /assets/ → маппинг матчит, immutable ставится.

Тест: bare-fastify + inject() — /assets/<hashed>.js содержит immutable+
max-age=31536000, /locales/en.json — нет. Мутационно: cacheControl:true роняет
ассерт immutable. jest static.module → 5/5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:30:44 +03:00
agent_coder 2c03fefa9d fix(ai-chat): защита от петель агента — lockdown под тоггл, детектор деградации, бюджет шагов (#444)
Третий класс петли (инцидент 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>
2026-07-10 07:28:48 +03:00
vvzvlad 90396a5b61 Merge pull request 'perf(server): низковисящие бэкенд-оптимизации — индексы, auth-дедуп, коалесинг эмбеда, CTE short-circuit (#348)' (#364) from perf/348-backend-lowhanging into develop
Reviewed-on: #364
2026-07-10 07:03:52 +03:00
vvzvlad e4487d8628 Merge pull request 'perf(delivery): пре-сжатие статики + кэш-заголовки + сжатие API (#346)' (#352) from perf/346-compression-cache into develop
Reviewed-on: #352
2026-07-10 07:03:03 +03:00
vvzvlad e3dc73e40f Merge pull request 'feat(tools): пассивный сигнал «new comments: N» в результатах tool-вызовов' (#428) from feat/417-new-comments-signal into develop
Reviewed-on: #428
2026-07-10 07:01:52 +03:00
vvzvlad 3a55c3097d Merge pull request 'perf(comment): унести Yjs-обновление comment-mark с HTTP-критического пути (#399)' (#438) from perf/399-comment-resolve-async into develop
Reviewed-on: #438
2026-07-10 07:01:44 +03:00
vvzvlad 144ffb07f5 Merge pull request 'refactor(mcp): дедупликация конвертер-смежных хелперов (node-ops форк, footnote-*, parse-node-arg)' (#429) from refactor/414-dedup-node-ops into develop
Reviewed-on: #429
2026-07-10 07:01:07 +03:00
agent_coder d84e5ddbad test(comment): покрыть fire-and-forget resolve-путь при отказе очереди (ревью #438)
resolve/unresolve enqueue — fire-and-forget (void ...catch(warn)): смысл #399
в том, что недоступность очереди НЕ должна ронять HTTP-запрос. Delete-путь уже
покрыт (enqueue awaited перед hard-delete), а reject resolve-пути — нет. Тест:
generalQueue.add реджектит -> resolveComment всё равно resolves (не throws) +
warn залогирован (ошибка проглочена на микротаске после возврата, поэтому
flushMicrotasks перед ассертом). Мутационно: сделать enqueue awaited без catch
-> тест краснеет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 05:32:08 +03:00
agent_coder 6bf8361936 perf(comment): унести Yjs-обновление comment-mark с HTTP-критического пути (#399)
resolve/unresolve/delete comment-mark синхронно дёргали collab-gateway
(handleYjsEvent) прямо в HTTP-запросе — сетевой раунд-трип к collab на
горячем пути. Теперь:
- DB-строка пишется синхронно (источник истины) с общим таймстампом;
- сама mark-операция уходит идемпотентным COMMENT_MARK_UPDATE-джобом на
  GENERAL_QUEUE, воркер проигрывает тот же handleYjsEvent;
- resolve/unresolve — fire-and-forget (best-effort), delete — await энкью
  ДО необратимого hard-delete (durability split);
- race-guard: устаревшее ПРОТИВОПОЛОЖНОЕ событие (ts <= updatedAt строки и
  состояние расходится) пропускается, а не флипает mark в устаревшее;
- DI-цикл обойдён ленивым moduleRef.get(CollaborationGateway, strict:false).

Внутренний цикл: 2 прохода. Правки по внутреннему ревью: `<` → `<=` в
race-guard (безопасная обработка суб-миллисекундной ничьи двух
противоположных тогглов); задокументирован сознательный компромисс —
транзиентный page.updated-broadcast из воркера несёт только {id}, теряя
name/avatarUrl «кто редактировал» (lastUpdatedById выставляется верно,
косметика, самочинится на следующем реальном редактировании).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 05:15:11 +03:00
agent_vscode f46d89eafb fix(ai-chat): wire drawio CRUD tools in-app to restore SHARED_TOOL_SPECS parity
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>
2026-07-10 04:51:24 +03:00
agent_coder e609832ae4 fix(#348 review round-2 F5-F6): index page_access(workspace_id) + test the workspace-cache bust
Both are direct consequences of the round-1 F1 fix (uncaching
hasRestrictedPagesInWorkspace):

- F5: that EXISTS(SELECT 1 FROM page_access WHERE workspace_id=?) now runs
  per-request on every whole-workspace list endpoint (global search + suggest,
  favorites, notifications, recent, created-by), and page_access only had a
  space_id index → a seq scan in the common zero-restriction case. Added
  idx_page_access_workspace_id to the perf migration (up + down) so it's an
  index-only existence probe.
- F6: the DomainMiddleware workspace cache invalidation was untested — the
  int-spec passed `{}` for cacheManager, so bustWorkspaceCache's `del` threw into
  its own try/catch and never ran. Added a Map-backed cache double with a working
  del and two tests: updateSetting busts WORKSPACE_SELF_HOSTED; updateSharingSettings
  busts WORKSPACE_SELF_HOSTED + WORKSPACE_BY_HOST(hostname). A missed/mismatched
  bust key now fails the suite instead of letting a stale security-relevant
  workspace row (enforceSso/status) outlive the mutation.

Gate: server tsc 0; workspace-repo-update-setting + page-permission-workspace-filter
int-specs pass on real Postgres (the new index applies via global-setup).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 04:32:38 +03:00
agent_coder ee03da4018 fix(#348 review F1-F4): uncache the workspace-restriction gate + int-spec + docs
- F1 [medium — the substantive one]: hasRestrictedPagesInWorkspace is now UNCACHED
  (a plain EXISTS per call, like its sibling hasRestrictedPagesInSpace). Caching it
  (even 5s) reintroduced an access-control leak the space path never had: a
  concurrent whole-workspace read in the insert->commit window of the FIRST
  restricted page could re-populate `false` under withCache (read-then-set, no
  del-during-read guard) and override the insert-time bust, leaking that page to
  unauthorized users for up to the TTL. Uncaching removes both the DB/cache
  asymmetry and the TOCTOU race; the space path already accepts this per-call cost.
  Reverted the now-unnecessary insertPageAccess cache-bust and removed the dead
  HAS_RESTRICTED_PAGES_IN_WORKSPACE cache key.
- F2 [test]: page-permission-workspace-filter.int-spec.ts (real PG) — the
  short-circuit returns the full input set with zero restrictions AND filters out
  the page the user can't reach when a restriction is present (proving the authz
  behavior is unchanged), the 0->1 transition flips immediately, and the flag is
  per-workspace scoped.
- F3 [doc]: documented the deploy-time write-lock in the migration header — the
  non-CONCURRENT GIN trigram builds take a SHARE lock that blocks writes on
  pages/users/… for minutes on a large tenant; run in a maintenance window or
  build CONCURRENTLY out-of-band for big installs.
- F4 [doc]: corrected the jwt.strategy comment — the reused req.raw.workspace is
  the middleware's selectAll superset (not "the exact row this query returns"),
  harmless because AuthWorkspace already preferred that object.

Gate: server tsc 0; the new int-spec 3/3 on real Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 04:32:38 +03:00
agent_coder 28251b1e08 perf(server): low-hanging backend wins — indexes, auth dedup, embed coalescing, CTE short-circuit (#348)
One migration + targeted hot-path fixes. API behavior 1:1 (schema change = added
indexes + a byte-identical f_unaccent function-body swap, see below).

- Trigram + composite indexes (20260705T120000-perf-indexes.ts): GIN trigram on
  LOWER(f_unaccent(title/name)) for pages/users/groups (the /search/suggest
  leading-wildcard LIKE did a seq scan per keystroke — EXPLAIN now confirms
  Bitmap Index Scan on idx_pages_title_trgm), + page_history(page_id,id DESC),
  comments(page_id,id). DEVIATION (verified byte-identical): PG18 cannot inline
  the two-arg f_unaccent body during index creation, so up() swaps it to the
  schema-qualified single-arg `SELECT public.unaccent($1)` — same dictionary,
  identical output for all inputs, so the tsvector trigger + main @@ search stay
  consistent with NO reindex; down() restores the exact two-arg body.
- Auth path: jwt.strategy reuses req.raw.workspace when workspaceId matches (the
  middleware already validated it) instead of re-querying; domain.middleware
  caches the workspace lookup (withCache 15s, invalidated in all 8 WorkspaceRepo
  mutators, with a Date reviver for the JSON-serialized cache). USER + SESSION
  caching DEFERRED — the invalidation surface (role change doesn't revoke
  sessions; revocation includes background jobs) can't be safely covered, and a
  missed hook on a security path is worse than the win.
- AI re-embed coalescing: aiQueue.add gets {jobId: embed-<id>, delay: 30s} so
  active editing collapses to one job (worker reads current page state).
- filterAccessiblePageIds: hasRestrictedPagesInWorkspace short-circuit skips the
  recursive-ancestor CTE when a workspace has zero restricted pages (wired from
  search/favorites/notifications/recent/created-by). EXISTS on the same pageAccess
  table the CTE anti-joins → no false-positive / no access leak. Busts the cache
  on insertPageAccess so a 0->1 restricted transition takes effect immediately
  (review F1).
- Small: syncTransclusion guarded by a family-node probe (both old+new content, so
  the removal path is preserved); mention notifications enqueue only when the set
  gained a member; redis maintainLock clears a prior interval (leak fix).

Skipped as risky (flagged): global ValidationPipe transform change; a pool-wide
statement_timeout (would kill long CREATE INDEX migrations on the same pool).
NOTE: kept the trash query's `content` select — the trash UI reads page.content
for its preview modal (review F3, would have regressed).

Gate: server tsc 0; jest page-permission/auth/search/persistence 15 suites pass;
migration up+down+idempotency verified on real PG18 with EXPLAIN confirming index
use. No new deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 04:32:38 +03:00
vvzvlad 86830b860d Merge pull request 'feat(ai-chat): авто-реконнект к detached-рану после живого обрыва SSE' (#432) from feat/430-live-reconnect into develop
Reviewed-on: #432
2026-07-10 04:27:16 +03:00
vvzvlad 9acbc07f7d Merge pull request 'feat(ai-chat): персист tool-error частей — упавшие тулы видны в истории и сохраняют текст ошибки' (#426) from feat/407-persist-tool-errors into develop
Reviewed-on: #426
2026-07-10 04:26:37 +03:00
agent_coder 9685074237 perf(collab): три фикса горячего пути сервера — connect-vs-unload гонка, двойная перекодировка, isDeepStrictEqual (замер)
Побочные находки инцидента #400 (правка большой таблицы через MCP подвешивает всё).

1. Гонка connect-vs-unload в @hocuspocus/server 3.4.4 (вероятный источник 25s
   connect-таймаутов): createDocument проверяет loadingDocuments/documents, но НЕ
   ждёт unloadingDocuments -> новое соединение может захендшейкаться на умирающий
   Document -> redis-sync идёт по пути 'doc не загружен', провайдер висит до
   таймаута. Апстрим (main) не починен. pnpm-патч (инфра как у yjs-патча): в начале
   createDocument await in-flight unload (обёрнут в try/catch — отклонённый unload
   не отравляет открытие, поведение как до патча), в ОБОИХ рантаймах (cjs+esm).
   Тест hocuspocus-unload-race: реальный createDocument с засеянным in-flight
   unload -> не грузит пока unload не осел; при откате патча тест краснеет.

2. Двойная перекодировка в onLoadDocument (persistence.extension.ts): хук строил
   НОВЫЙ Y.Doc и возвращал его -> hocuspocus делал applyUpdate(encodeStateAsUpdate)
   ВТОРОЙ раз (315КБ на каждую холодную загрузку); в JSON-ветке результат encode
   выбрасывался (мёртвый вызов). Теперь стейт применяется прямо в data.document,
   возврат undefined (hocuspocus мержит только при возврате Doc); мёртвый encode
   убран. Содержимое документа не меняется — только меньше encode/alloc.

3. isDeepStrictEqual по 84КБ JSON на каждом store: замерил — 1.32мс на 90КБ
   (immaterial, <50мс порога; доминируют fromYdoc+encodeStateAsUpdate). Изменений
   кода НЕТ по правилу задачи (dirty-флаг только при material).

closes #401

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 02:53:23 +03:00
agent_coder 22f687c39e feat(ai-chat): авто-реконнект к detached-рану после живого обрыва SSE
Автономный ран продолжается на сервере при обрыве SSE (Safari роняет длинный
стрим), но клиент показывал баннер 'Lost connection' и мёртвую вкладку до ручной
перезагрузки: resumeStream() звался только на mount. Добавлен недостающий триггер.

- chat-thread.tsx: в onFinish на живом isDisconnect (гард !wasResumed &&
  autonomousRunsEnabled && mounted && assistant) -> beginReconnect с экспон.
  backoff (1/2/4/8/16с, лимит 5). Стоп: status->streaming / 2xx re-attach /
  терминальный хвост reconcile / stop / unmount. Исчерпание -> Retry.
- Дедуп (главный риск): зеркалит mount strip/anchor — пиннит текущий streaming-ряд
  как anchor (id ассистент-строки), стрипает его из стора ДО replay, сервер
  ?expect=live&anchor=<id> пересобирает без дублей; на отказе/204 строка
  восстанавливается через onNoActiveStream (контент не теряется).
- 204/overflow -> degraded poll через существующий onNoActiveStream.
- RUN_STREAM_MAX_BUFFER_BYTES 4->32МБ (марафонские раны 11-25мин переполняли 4МБ);
  204->poll остаётся backstop. Degraded-poll: фиксированный 10-мин-от-старта кап
  заменён на inactivity-кап (продлевается пока приходят новые ряды).
- UI: баннер 'reconnecting… (N/5)' + ручной Retry на исчерпании.

closes #430

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 02:51:58 +03:00
agent_coder 4b2af3d34a refactor(mcp): дедупликация конвертер-смежных хелперов (node-ops форк, footnote-*, parse-node-arg)
Аудит при подготовке #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>
2026-07-10 01:06:31 +03:00
agent_coder ab40e82123 fix(tools): комментарийная обёртка КОМПОНУЕТ собственный toModelOutput тула, а не затирает (ревью #428)
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>
2026-07-10 00:58:55 +03:00
agent_coder dca9f2aaf0 feat(tools): пассивный сигнал «new comments: N» в результатах tool-вызовов (обе поверхности)
Комментарии человека находят агента сами: короткая эфемерная строка
'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>
2026-07-10 00:30:52 +03:00
agent_coder 654ba9f249 feat(ai-chat): персист tool-error частей — упавшие тулы видны в истории и сохраняют текст ошибки при реплее
В 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>
2026-07-09 23:35:32 +03:00
agent_vscode d90c3b8b9e fix(ai-chat): stop trimming tool outputs in replayed history
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>
2026-07-09 17:49:47 +03:00