047433595e
Локаторная нормализация markdown (stripInlineMarkdown + примитив stripWrappersAndLinks с WRAPPER_PATTERNS/LINK_IMAGE_RE) была ФОРКНУТА один-в- один в packages/mcp/src/lib/text-normalize.ts и в каноническом @docmost/prosemirror-markdown (где ей пользуется node-ops). MCP теперь импортирует оба примитива из пакета (mcp и так от него зависит — цикла нет) и держит на них лишь свои тонкие надстройки stripBalancedWrappers/ closestBlockHint. ~60 строк дубля удалено, дрейф закрыт. Проверено: пакет node-ops (103) + весь MCP unit-suite (685) зелёные. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
79 lines
3.6 KiB
TypeScript
79 lines
3.6 KiB
TypeScript
/**
|
|
* Locator normalization helpers for mcp. The two PRIMITIVES —
|
|
* `stripInlineMarkdown` (lenient locator normalizer) and `stripWrappersAndLinks`
|
|
* (strict balanced-wrapper/link collapse) — live in the canonical package
|
|
* `@docmost/prosemirror-markdown` (#493 dedup: they used to be forked verbatim
|
|
* here). This module now only re-exports `stripInlineMarkdown` and adds the two
|
|
* mcp-only helpers built on top: `stripBalancedWrappers` and `closestBlockHint`.
|
|
*
|
|
* They are used ONLY as a fallback for LOCATING (after an exact match fails) and
|
|
* for formatting-vs-plain intent detection; never applied to replacement text or
|
|
* inserted node content, so no formatting is ever lost.
|
|
*/
|
|
import {
|
|
stripInlineMarkdown,
|
|
stripWrappersAndLinks,
|
|
} from "@docmost/prosemirror-markdown";
|
|
|
|
// Re-export the canonical locator normalizer so mcp call sites keep importing it
|
|
// from `./text-normalize.js` unchanged.
|
|
export { stripInlineMarkdown };
|
|
|
|
/**
|
|
* STRICT formatting detector — distinct from the lenient locator normalization.
|
|
* It strips ONLY what unambiguously is markdown markup (links/images to visible
|
|
* text, and balanced inline `**`/`__`/`~~`/`*`/`_`/`` ` `` wrappers) and
|
|
* DELIBERATELY does NOT trim leading/trailing whitespace, emoji, or lone marker
|
|
* chars (the lenient extras `stripInlineMarkdown` does).
|
|
*
|
|
* It exists ONLY to recognize formatting-vs-plain INTENT in `applyTextEdits`
|
|
* (deciding whether find/replace differ purely by markdown markers). Because it
|
|
* skips the lenient trimming, ordinary plain-text edits are NOT misread as
|
|
* formatting: a trailing-space trim, snake_case (`my_var_name`), math (`2 * 3`),
|
|
* and identifiers/URLs with underscores all stay untouched here (their `_x_` /
|
|
* `*x*` runs are only collapsed when actually balanced, and even then they are
|
|
* compared symmetrically, so plain text never collapses to a different string).
|
|
*
|
|
* Do NOT use this for LOCATING — the locator fallback must keep using the
|
|
* lenient `stripInlineMarkdown` (it trims stray decoration so a find still
|
|
* matches the document's plain text).
|
|
*/
|
|
export function stripBalancedWrappers(s: string): string {
|
|
if (typeof s !== "string" || s.length === 0) return s;
|
|
return stripWrappersAndLinks(s);
|
|
}
|
|
|
|
/**
|
|
* Build a bounded "closest text" hint for an anchor/find MISS, shared by
|
|
* editPageText (json-edit) and createComment (client) so both surface the
|
|
* same self-correction affordance.
|
|
*
|
|
* Take the longest whitespace-delimited token (>= 3 chars) of the locator
|
|
* (markdown-stripped first, so `**bold**` contributes `bold`), find the FIRST
|
|
* of `blockTexts` that contains it, and return ` Closest block text: "…".` with
|
|
* the block quoted (truncated to 120 code points + ellipsis). Returns "" when
|
|
* no token qualifies or no block contains it, so the caller can append it
|
|
* unconditionally.
|
|
*/
|
|
export function closestBlockHint(
|
|
blockTexts: string[],
|
|
locator: string,
|
|
): string {
|
|
if (typeof locator !== "string" || locator.length === 0) return "";
|
|
const stripped = stripInlineMarkdown(locator);
|
|
const tokenSource = stripped.length > 0 ? stripped : locator;
|
|
const longestToken = tokenSource
|
|
.split(/\s+/)
|
|
.filter((t) => t.length >= 3)
|
|
.sort((a, b) => b.length - a.length)[0];
|
|
if (!longestToken) return "";
|
|
const hitBlock = blockTexts.find((plain) => plain.includes(longestToken));
|
|
if (!hitBlock) return "";
|
|
// Truncate by code point (spread iterates by code point) so a surrogate pair
|
|
// is never split; append the ellipsis only when the text was actually longer.
|
|
const points = [...hitBlock];
|
|
const snippet =
|
|
points.length > 120 ? points.slice(0, 120).join("") + "…" : hitBlock;
|
|
return ` Closest block text: "${snippet}".`;
|
|
}
|