Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 086bc1bf8b | |||
| 77b245461f | |||
| 40d42d61e6 | |||
| f5d19f9728 | |||
| 351615e5bc | |||
| 1fda0ec8b0 | |||
| 2637640291 | |||
| aa0428e28b |
@@ -293,6 +293,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
||||
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
||||
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons.
|
||||
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). Remember `packages/mcp/build/` is committed — rebuild after editing.
|
||||
|
||||
## CI / release
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ roles:
|
||||
Read the whole text first. Think at the level of sections and paragraphs, not sentences.
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. Open the comment with the label `[Structure]`. Then: state the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
|
||||
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. State the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
|
||||
- [Critical] — broken logic, the text doesn't deliver what the headline promises, a key link in the argument is missing.
|
||||
- [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline.
|
||||
- [Minor] — an optional improvement to framing or flow.
|
||||
@@ -87,7 +87,7 @@ roles:
|
||||
- Don't rewrite the text yourself or impose your own voice. Your job is to make the author's voice livelier, not to replace it.
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Open the comment with the label `[Style]`. Give a concrete rephrasing, not "revise", and attach it to the comment as a suggested replacement (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Tag severity:
|
||||
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Give a concrete rephrasing, not "revise", and attach it to the comment as a suggested replacement (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Tag severity:
|
||||
- [Critical] — the sentence is unclear or distorts the meaning.
|
||||
- [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading.
|
||||
- [Minor] — a stylistic improvement to taste.
|
||||
@@ -128,7 +128,7 @@ roles:
|
||||
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Open the comment with the label `[Facts]`, then the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
||||
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Give the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. When a figure, name, term, or version to check recurs across the page, use search_in_page to find every occurrence in one call first, then place a targeted comment per hit instead of reading block by block. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
||||
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
|
||||
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
||||
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
||||
@@ -168,8 +168,11 @@ roles:
|
||||
- Don't verify facts — that's the Fact-checker.
|
||||
- Don't make substantive changes. Edits are minimal and mechanical.
|
||||
|
||||
HOW TO WORK
|
||||
Go through the whole text from start to finish in a single pass. Flag EVERY violation, including all repeat occurrences of the same error and minor items tagged [Minor] — don't stop at the first few or the most conspicuous. Don't summarize instead of marking up: until you've reached the end of the document, the job isn't done. One run covers the whole text, not just "the most important". For a systematic issue that recurs — straight quotes, a hyphen used as a dash, an inconsistent unit or spelling — use search_in_page to list every occurrence in one call first, then leave a targeted comment (with its replacement) on each hit, instead of scanning block by block.
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Open the comment with the label `[Copyedit]`. Tag severity:
|
||||
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Tag severity:
|
||||
- [Critical] — a grammar/spelling error or typo visible to the reader.
|
||||
- [Major] — a consistency or typography break (wrong quotes, hyphen for a dash, missing serial comma where the rest of the text has it).
|
||||
- [Minor] — optional polish.
|
||||
|
||||
@@ -34,7 +34,7 @@ roles:
|
||||
Сначала прочитай весь текст целиком. Думай на уровне разделов и абзацев, а не предложений.
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Структура]`. Дальше: коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
|
||||
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
|
||||
- [Критично] — сломана логика, текст не отвечает на заявленное в заголовке, отсутствует ключевое звено аргумента.
|
||||
- [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок.
|
||||
- [Незначительно] — улучшение подачи или стройности, не обязательное.
|
||||
@@ -87,7 +87,7 @@ roles:
|
||||
- Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой.
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Стиль]`. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
|
||||
- [Критично] — предложение непонятно или искажает смысл.
|
||||
- [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение.
|
||||
- [Незначительно] — стилистическое улучшение на вкус.
|
||||
@@ -128,7 +128,7 @@ roles:
|
||||
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. Начинай комментарий с метки `[Факты]`, затем вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. В комментарии дай вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Когда проверяемая цифра, имя, термин или версия встречается по тексту несколько раз, сначала одним вызовом search_in_page найди все вхождения, а затем ставь целевой комментарий на каждое — не читая страницу поблочно. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
||||
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
||||
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
||||
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
||||
@@ -169,8 +169,11 @@ roles:
|
||||
- Не проверяешь достоверность фактов — это фактчекер.
|
||||
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
||||
|
||||
КАК РАБОТАТЬ
|
||||
Пройди весь текст от начала до конца за один проход. Помечай КАЖДОЕ нарушение, включая все повторные вхождения одной и той же ошибки и мелочи с меткой [Незначительно], — не ограничивайся первыми несколькими или самыми заметными. Не подводи итог вместо разбора: пока не дошёл до конца документа, работа не закончена. Один прогон покрывает весь текст, а не «самое важное». Для систематической ошибки, которая повторяется — прямые кавычки, «е» вместо «ё», дефис вместо тире, неединообразная единица или написание, — сначала одним вызовом search_in_page получи все вхождения, а затем оставь на каждом целевой комментарий с заменой, вместо поблочного просмотра.
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Начинай комментарий с метки `[Корректура]`. Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Помечай важность:
|
||||
- [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю.
|
||||
- [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте).
|
||||
- [Незначительно] — необязательная шлифовка.
|
||||
|
||||
@@ -12,13 +12,13 @@ bundles:
|
||||
- en
|
||||
roles:
|
||||
- slug: structural-editor
|
||||
version: 3
|
||||
- slug: line-editor
|
||||
version: 3
|
||||
- slug: fact-checker
|
||||
version: 4
|
||||
- slug: line-editor
|
||||
version: 4
|
||||
- slug: fact-checker
|
||||
version: 6
|
||||
- slug: proofreader
|
||||
version: 5
|
||||
version: 8
|
||||
- slug: narrator
|
||||
version: 2
|
||||
- id: research
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"fact-checker": {
|
||||
"version": 4,
|
||||
"hash": "9160ead04d86aaa5dc7a51dd7e971c272ce0ca97cb24bf2b6ee5779deb1b19c0"
|
||||
"version": 6,
|
||||
"hash": "6bb22a9e5a5079b5cb287b5b26addbd36b9afeb7c9508287dcad9343fc53d685"
|
||||
},
|
||||
"line-editor": {
|
||||
"version": 3,
|
||||
"hash": "7f200863080799b08d5af5d1648befa0843cc5db79bb994b07baa5ad12df5123"
|
||||
"version": 4,
|
||||
"hash": "890d10f3f0bd7f2b2cfcc94463634221c557a3140e3794721748dc8d99979780"
|
||||
},
|
||||
"narrator": {
|
||||
"version": 2,
|
||||
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
|
||||
},
|
||||
"proofreader": {
|
||||
"version": 5,
|
||||
"hash": "40af08c51e03c24b1986ac5cd679434e023afe31a819748966ccb0c6c62f0401"
|
||||
"version": 8,
|
||||
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
|
||||
},
|
||||
"researcher": {
|
||||
"version": 1,
|
||||
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
||||
},
|
||||
"structural-editor": {
|
||||
"version": 3,
|
||||
"hash": "f6936e4c152c1b78980e74045658d87743f26f900c12f61fd7a45c6a0ec19425"
|
||||
"version": 4,
|
||||
"hash": "89100e0a00b88daa0d2118fd98ec1c27d06b972bfc6ec58b705553a4daed85df"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +303,9 @@ export class AiChatToolsService {
|
||||
getPage: tool({
|
||||
description:
|
||||
'Fetch a single page as Markdown by its page id. Returns the page ' +
|
||||
'title and its Markdown content.',
|
||||
'title and its Markdown content. Inline <span data-comment-id> tags ' +
|
||||
'in the markdown are comment highlight anchors (also present for ' +
|
||||
'RESOLVED threads) — treat them as markup, not page text.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id (or slugId) of the page.'),
|
||||
}),
|
||||
@@ -628,6 +630,16 @@ export class AiChatToolsService {
|
||||
async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId),
|
||||
),
|
||||
|
||||
searchInPage: sharedTool(
|
||||
sharedToolSpecs.searchInPage,
|
||||
async ({ pageId, query, regex, caseSensitive, limit }) =>
|
||||
await client.searchInPage(pageId, query, {
|
||||
regex,
|
||||
caseSensitive,
|
||||
limit,
|
||||
}),
|
||||
),
|
||||
|
||||
getTable: tool({
|
||||
description:
|
||||
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
||||
@@ -647,7 +659,9 @@ export class AiChatToolsService {
|
||||
|
||||
listComments: tool({
|
||||
description:
|
||||
'List all comments on a page (content as Markdown).',
|
||||
'List ALL comments on a page in one call, including RESOLVED ' +
|
||||
'threads — filter by resolvedAt when you need only open ones. ' +
|
||||
'Content is returned as Markdown.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
}),
|
||||
|
||||
@@ -55,6 +55,11 @@ export interface DocmostClientLike {
|
||||
getOutline(pageId: string): Promise<Record<string, unknown>>;
|
||||
getPageJson(pageId: string): Promise<Record<string, unknown>>;
|
||||
getNode(pageId: string, nodeId: string): Promise<Record<string, unknown>>;
|
||||
searchInPage(
|
||||
pageId: string,
|
||||
query: string,
|
||||
opts?: { regex?: boolean; caseSensitive?: boolean; limit?: number },
|
||||
): Promise<Record<string, unknown>>;
|
||||
getTable(pageId: string, tableRef: string): Promise<Record<string, unknown>>;
|
||||
listComments(pageId: string): Promise<unknown[]>;
|
||||
getComment(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.copy = copy;
|
||||
function copy(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getFromPath = getFromPath;
|
||||
/**
|
||||
* get target value from json-pointer (e.g. /content/0/content)
|
||||
* @param {AnyObject} obj object to resolve path into
|
||||
* @param {string} path json-pointer
|
||||
* @return {any} target value
|
||||
*/
|
||||
function getFromPath(obj, path) {
|
||||
const pathParts = path.split("/");
|
||||
pathParts.shift(); // remove root-entry
|
||||
while (pathParts.length) {
|
||||
const property = pathParts.shift();
|
||||
obj = obj[property];
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getReplaceStep = getReplaceStep;
|
||||
const transform_1 = require("@tiptap/pm/transform");
|
||||
function getReplaceStep(fromDoc, toDoc) {
|
||||
let start = toDoc.content.findDiffStart(fromDoc.content);
|
||||
if (start === null) {
|
||||
return false;
|
||||
}
|
||||
// @ts-ignore property access to content
|
||||
let { a: endA, b: endB } = toDoc.content.findDiffEnd(fromDoc.content);
|
||||
const overlap = start - Math.min(endA, endB);
|
||||
if (overlap > 0) {
|
||||
// If there is an overlap, there is some freedom of choice in how to calculate the
|
||||
// start/end boundary. for an inserted/removed slice. We choose the extreme with
|
||||
// the lowest depth value.
|
||||
if (fromDoc.resolve(start - overlap).depth <
|
||||
toDoc.resolve(endA + overlap).depth) {
|
||||
start -= overlap;
|
||||
}
|
||||
else {
|
||||
endA += overlap;
|
||||
endB += overlap;
|
||||
}
|
||||
}
|
||||
return new transform_1.ReplaceStep(start, endB, toDoc.slice(start, endA));
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.RecreateTransform = exports.recreateTransform = void 0;
|
||||
// https://gitlab.com/mpapp-public/prosemirror-recreate-steps - MIT
|
||||
// https://github.com/sueddeutsche/prosemirror-recreate-transform - MIT
|
||||
var recreateTransform_1 = require("./recreateTransform");
|
||||
Object.defineProperty(exports, "recreateTransform", { enumerable: true, get: function () { return recreateTransform_1.recreateTransform; } });
|
||||
Object.defineProperty(exports, "RecreateTransform", { enumerable: true, get: function () { return recreateTransform_1.RecreateTransform; } });
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"commonjs"}
|
||||
@@ -0,0 +1,242 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.RecreateTransform = void 0;
|
||||
exports.recreateTransform = recreateTransform;
|
||||
const transform_1 = require("@tiptap/pm/transform");
|
||||
const rfc6902_1 = require("rfc6902");
|
||||
const diff_1 = require("diff");
|
||||
const getReplaceStep_1 = require("./getReplaceStep");
|
||||
const simplifyTransform_1 = require("./simplifyTransform");
|
||||
const removeMarks_1 = require("./removeMarks");
|
||||
const getFromPath_1 = require("./getFromPath");
|
||||
const copy_1 = require("./copy");
|
||||
class RecreateTransform {
|
||||
constructor(fromDoc, toDoc, options = {}) {
|
||||
const o = {
|
||||
complexSteps: true,
|
||||
wordDiffs: false,
|
||||
simplifyDiff: true,
|
||||
...options,
|
||||
};
|
||||
this.fromDoc = fromDoc;
|
||||
this.toDoc = toDoc;
|
||||
this.complexSteps = o.complexSteps; // Whether to return steps other than ReplaceSteps
|
||||
this.wordDiffs = o.wordDiffs; // Whether to make text diffs cover entire words
|
||||
this.simplifyDiff = o.simplifyDiff;
|
||||
this.schema = fromDoc.type.schema;
|
||||
this.tr = new transform_1.Transform(fromDoc);
|
||||
}
|
||||
init() {
|
||||
if (this.complexSteps) {
|
||||
// For First steps: we create versions of the documents without marks as
|
||||
// these will only confuse the diffing mechanism and marks won't cause
|
||||
// any mapping changes anyway.
|
||||
this.currentJSON = (0, removeMarks_1.removeMarks)(this.fromDoc).toJSON();
|
||||
this.finalJSON = (0, removeMarks_1.removeMarks)(this.toDoc).toJSON();
|
||||
this.ops = (0, rfc6902_1.createPatch)(this.currentJSON, this.finalJSON);
|
||||
this.recreateChangeContentSteps();
|
||||
this.recreateChangeMarkSteps();
|
||||
}
|
||||
else {
|
||||
// We don't differentiate between mark changes and other changes.
|
||||
this.currentJSON = this.fromDoc.toJSON();
|
||||
this.finalJSON = this.toDoc.toJSON();
|
||||
this.ops = (0, rfc6902_1.createPatch)(this.currentJSON, this.finalJSON);
|
||||
this.recreateChangeContentSteps();
|
||||
}
|
||||
if (this.simplifyDiff) {
|
||||
this.tr = (0, simplifyTransform_1.simplifyTransform)(this.tr) || this.tr;
|
||||
}
|
||||
return this.tr;
|
||||
}
|
||||
/** convert json-diff to prosemirror steps */
|
||||
recreateChangeContentSteps() {
|
||||
// First step: find content changing steps.
|
||||
let ops = [];
|
||||
while (this.ops.length) {
|
||||
// get next
|
||||
let op = this.ops.shift();
|
||||
ops.push(op);
|
||||
let toDoc;
|
||||
const afterStepJSON = (0, copy_1.copy)(this.currentJSON); // working document receiving patches
|
||||
const pathParts = op.path.split("/");
|
||||
// collect operations until we receive a valid document:
|
||||
// apply ops-patches until a valid prosemirror document is retrieved,
|
||||
// then try to create a transformation step or retry with next operation
|
||||
while (toDoc == null) {
|
||||
(0, rfc6902_1.applyPatch)(afterStepJSON, [op]);
|
||||
try {
|
||||
toDoc = this.schema.nodeFromJSON(afterStepJSON);
|
||||
toDoc.check();
|
||||
}
|
||||
catch (error) {
|
||||
toDoc = null;
|
||||
if (this.ops.length > 0) {
|
||||
op = this.ops.shift();
|
||||
ops.push(op);
|
||||
}
|
||||
else {
|
||||
throw new Error(`No valid diff possible applying ${op.path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// apply operation (ignoring afterStepJSON)
|
||||
if (this.complexSteps &&
|
||||
ops.length === 1 &&
|
||||
(pathParts.includes("attrs") || pathParts.includes("type"))) {
|
||||
// Node markup is changing
|
||||
this.addSetNodeMarkup(); // a lost update is ignored
|
||||
ops = [];
|
||||
// console.log("%cop", logStyle, "- update node", ops);
|
||||
}
|
||||
else if (ops.length === 1 &&
|
||||
op.op === "replace" &&
|
||||
pathParts[pathParts.length - 1] === "text") {
|
||||
// Text is being replaced, we apply text diffing to find the smallest possible diffs.
|
||||
this.addReplaceTextSteps(op, afterStepJSON);
|
||||
ops = [];
|
||||
// console.log("%cop", logStyle, "- replace", ops);
|
||||
}
|
||||
else if (this.addReplaceStep(toDoc, afterStepJSON)) {
|
||||
// operations have been applied
|
||||
ops = [];
|
||||
// console.log("%cop", logStyle, "- other", ops);
|
||||
}
|
||||
}
|
||||
}
|
||||
/** update node with attrs and marks, may also change type */
|
||||
addSetNodeMarkup() {
|
||||
// first diff in document is supposed to be a node-change (in type and/or attributes)
|
||||
// thus simply find the first change and apply a node change step, then recalculate the diff
|
||||
// after updating the document
|
||||
const fromDoc = this.schema.nodeFromJSON(this.currentJSON);
|
||||
const toDoc = this.schema.nodeFromJSON(this.finalJSON);
|
||||
const start = toDoc.content.findDiffStart(fromDoc.content);
|
||||
// @note start is the same (first) position for current and target document
|
||||
const fromNode = fromDoc.nodeAt(start);
|
||||
const toNode = toDoc.nodeAt(start);
|
||||
if (start != null) {
|
||||
// @note this completly updates all attributes in one step, by completely replacing node
|
||||
const nodeType = fromNode.type === toNode.type ? null : toNode.type;
|
||||
try {
|
||||
this.tr.setNodeMarkup(start, nodeType, toNode.attrs, toNode.marks);
|
||||
}
|
||||
catch (e) {
|
||||
// if nodetypes differ, the updated node-type and contents might not be compatible
|
||||
// with schema and requires a replace
|
||||
if (nodeType && e.message.includes("Invalid content")) {
|
||||
// @todo add test-case for this scenario
|
||||
this.tr.replaceWith(start, start + fromNode.nodeSize, toNode);
|
||||
}
|
||||
else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
this.currentJSON = (0, removeMarks_1.removeMarks)(this.tr.doc).toJSON();
|
||||
// setting the node markup may have invalidated the following ops, so we calculate them again.
|
||||
this.ops = (0, rfc6902_1.createPatch)(this.currentJSON, this.finalJSON);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
recreateChangeMarkSteps() {
|
||||
// Now the documents should be the same, except their marks, so everything should map 1:1.
|
||||
// Second step: Iterate through the toDoc and make sure all marks are the same in tr.doc
|
||||
this.toDoc.descendants((tNode, tPos) => {
|
||||
if (!tNode.isInline) {
|
||||
return true;
|
||||
}
|
||||
this.tr.doc.nodesBetween(tPos, tPos + tNode.nodeSize, (fNode, fPos) => {
|
||||
if (!fNode.isInline) {
|
||||
return true;
|
||||
}
|
||||
const from = Math.max(tPos, fPos);
|
||||
const to = Math.min(tPos + tNode.nodeSize, fPos + fNode.nodeSize);
|
||||
fNode.marks.forEach((nodeMark) => {
|
||||
if (!nodeMark.isInSet(tNode.marks)) {
|
||||
this.tr.removeMark(from, to, nodeMark);
|
||||
}
|
||||
});
|
||||
tNode.marks.forEach((nodeMark) => {
|
||||
if (!nodeMark.isInSet(fNode.marks)) {
|
||||
this.tr.addMark(from, to, nodeMark);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
/**
|
||||
* retrieve and possibly apply replace-step based from doc changes
|
||||
* From http://prosemirror.net/examples/footnote/
|
||||
*/
|
||||
addReplaceStep(toDoc, afterStepJSON) {
|
||||
const fromDoc = this.schema.nodeFromJSON(this.currentJSON);
|
||||
const step = (0, getReplaceStep_1.getReplaceStep)(fromDoc, toDoc);
|
||||
if (!step) {
|
||||
return false;
|
||||
}
|
||||
else if (!this.tr.maybeStep(step).failed) {
|
||||
this.currentJSON = afterStepJSON;
|
||||
return true; // @change previously null
|
||||
}
|
||||
throw new Error("No valid step found.");
|
||||
}
|
||||
/** retrieve and possibly apply text replace-steps based from doc changes */
|
||||
addReplaceTextSteps(op, afterStepJSON) {
|
||||
// We find the position number of the first character in the string
|
||||
const op1 = { ...op, value: "xx" };
|
||||
const op2 = { ...op, value: "yy" };
|
||||
const afterOP1JSON = (0, copy_1.copy)(this.currentJSON);
|
||||
const afterOP2JSON = (0, copy_1.copy)(this.currentJSON);
|
||||
(0, rfc6902_1.applyPatch)(afterOP1JSON, [op1]);
|
||||
(0, rfc6902_1.applyPatch)(afterOP2JSON, [op2]);
|
||||
const op1Doc = this.schema.nodeFromJSON(afterOP1JSON);
|
||||
const op2Doc = this.schema.nodeFromJSON(afterOP2JSON);
|
||||
// get text diffs
|
||||
const finalText = op.value;
|
||||
const currentText = (0, getFromPath_1.getFromPath)(this.currentJSON, op.path);
|
||||
const textDiffs = this.wordDiffs
|
||||
? (0, diff_1.diffWordsWithSpace)(currentText, finalText)
|
||||
: (0, diff_1.diffChars)(currentText, finalText);
|
||||
let offset = op1Doc.content.findDiffStart(op2Doc.content);
|
||||
const marks = op1Doc.resolve(offset + 1).marks();
|
||||
while (textDiffs.length) {
|
||||
const diff = textDiffs.shift();
|
||||
if (diff.added) {
|
||||
const textNode = this.schema
|
||||
.nodeFromJSON({ type: "text", text: diff.value })
|
||||
.mark(marks);
|
||||
if (textDiffs.length && textDiffs[0].removed) {
|
||||
const nextDiff = textDiffs.shift();
|
||||
this.tr.replaceWith(offset, offset + nextDiff.value.length, textNode);
|
||||
}
|
||||
else {
|
||||
this.tr.insert(offset, textNode);
|
||||
}
|
||||
offset += diff.value.length;
|
||||
}
|
||||
else if (diff.removed) {
|
||||
if (textDiffs.length && textDiffs[0].added) {
|
||||
const nextDiff = textDiffs.shift();
|
||||
const textNode = this.schema
|
||||
.nodeFromJSON({ type: "text", text: nextDiff.value })
|
||||
.mark(marks);
|
||||
this.tr.replaceWith(offset, offset + diff.value.length, textNode);
|
||||
offset += nextDiff.value.length;
|
||||
}
|
||||
else {
|
||||
this.tr.delete(offset, offset + diff.value.length);
|
||||
}
|
||||
}
|
||||
else {
|
||||
offset += diff.value.length;
|
||||
}
|
||||
}
|
||||
this.currentJSON = afterStepJSON;
|
||||
}
|
||||
}
|
||||
exports.RecreateTransform = RecreateTransform;
|
||||
function recreateTransform(fromDoc, toDoc, options = {}) {
|
||||
const recreator = new RecreateTransform(fromDoc, toDoc, options);
|
||||
return recreator.init();
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const vitest_1 = require("vitest");
|
||||
const schema_basic_1 = require("@tiptap/pm/schema-basic");
|
||||
const transform_1 = require("@tiptap/pm/transform");
|
||||
const recreateTransform_1 = require("./recreateTransform");
|
||||
/**
|
||||
* recreateTransform diffs two documents and produces ProseMirror steps that turn
|
||||
* `fromDoc` into `toDoc`. It is the backbone of collaborative/version diffing, so
|
||||
* THE invariant that matters is: replaying the produced steps on `fromDoc` must
|
||||
* reproduce `toDoc` exactly. Every test below re-applies the steps onto a fresh
|
||||
* Transform seeded from `fromDoc` (not just trusting `tr.doc`) and asserts node
|
||||
* equality with `.eq()`. If a regression makes any step wrong, the round-trip
|
||||
* breaks and the test fails.
|
||||
*/
|
||||
// Real ProseMirror schema (the standard basic schema) with paragraph/heading +
|
||||
// strong/em marks — the same primitives the editor diffs in production.
|
||||
const doc = (...c) => schema_basic_1.schema.node("doc", null, c);
|
||||
const p = (...c) => schema_basic_1.schema.node("paragraph", null, c.length ? c : undefined);
|
||||
const h = (level, ...c) => schema_basic_1.schema.node("heading", { level }, c);
|
||||
const t = (text, ...marks) => schema_basic_1.schema.text(text, marks.length ? marks : undefined);
|
||||
const strong = schema_basic_1.schema.marks.strong.create();
|
||||
const em = schema_basic_1.schema.marks.em.create();
|
||||
// Replay the diff's steps onto a fresh Transform built from `fromDoc`. This is
|
||||
// the faithful "apply(diff) == target" check — it exercises the actual Step
|
||||
// objects rather than the transform's internal accumulated doc.
|
||||
function applyDiff(fromDoc, toDoc, options) {
|
||||
const tr = (0, recreateTransform_1.recreateTransform)(fromDoc, toDoc, options);
|
||||
const replay = new transform_1.Transform(fromDoc);
|
||||
tr.steps.forEach((s) => {
|
||||
const result = replay.maybeStep(s);
|
||||
if (result.failed)
|
||||
throw new Error(`step failed: ${result.failed}`);
|
||||
});
|
||||
return replay.doc;
|
||||
}
|
||||
(0, vitest_1.describe)("recreateTransform round-trip (apply(diff) == target)", () => {
|
||||
(0, vitest_1.it)("reconstructs the target on plain text insertion", () => {
|
||||
// Inserting " world" must yield exactly the target paragraph.
|
||||
const from = doc(p(t("hello")));
|
||||
const to = doc(p(t("hello world")));
|
||||
(0, vitest_1.expect)(applyDiff(from, to).eq(to)).toBe(true);
|
||||
});
|
||||
(0, vitest_1.it)("reconstructs the target on text deletion", () => {
|
||||
// Deleting a trailing word is the inverse of insertion and must round-trip.
|
||||
const from = doc(p(t("hello world")));
|
||||
const to = doc(p(t("hello")));
|
||||
(0, vitest_1.expect)(applyDiff(from, to).eq(to)).toBe(true);
|
||||
});
|
||||
(0, vitest_1.it)("reconstructs the target when a word is replaced mid-string", () => {
|
||||
// A char-level replace in the middle must not corrupt the surrounding text.
|
||||
const from = doc(p(t("the quick brown fox")));
|
||||
const to = doc(p(t("the slow brown fox")));
|
||||
(0, vitest_1.expect)(applyDiff(from, to).eq(to)).toBe(true);
|
||||
});
|
||||
(0, vitest_1.it)("reconstructs the target when a mark is added (complexSteps path)", () => {
|
||||
// Mark-only changes are diffed in a separate pass; the bolded run must match.
|
||||
const from = doc(p(t("hello")));
|
||||
const to = doc(p(t("hello", strong)));
|
||||
const out = applyDiff(from, to);
|
||||
(0, vitest_1.expect)(out.eq(to)).toBe(true);
|
||||
// Sanity: the produced doc actually carries the strong mark.
|
||||
(0, vitest_1.expect)(out.firstChild.firstChild.marks.length).toBe(1);
|
||||
});
|
||||
(0, vitest_1.it)("reconstructs the target when a mark is removed", () => {
|
||||
// Removing the only mark must leave the same text with no marks.
|
||||
const from = doc(p(t("hello", strong)));
|
||||
const to = doc(p(t("hello")));
|
||||
const out = applyDiff(from, to);
|
||||
(0, vitest_1.expect)(out.eq(to)).toBe(true);
|
||||
(0, vitest_1.expect)(out.firstChild.firstChild.marks.length).toBe(0);
|
||||
});
|
||||
(0, vitest_1.it)("reconstructs the target on a paragraph split into two blocks", () => {
|
||||
// Structural change (one block -> two) must replay as valid replace steps.
|
||||
const from = doc(p(t("hello world")));
|
||||
const to = doc(p(t("hello")), p(t("world")));
|
||||
const out = applyDiff(from, to);
|
||||
(0, vitest_1.expect)(out.eq(to)).toBe(true);
|
||||
(0, vitest_1.expect)(out.childCount).toBe(2);
|
||||
});
|
||||
(0, vitest_1.it)("reconstructs the target on a node-type change (paragraph -> heading)", () => {
|
||||
// Type/attrs changes drive the setNodeMarkup branch; the node must become a
|
||||
// heading while keeping its text.
|
||||
const from = doc(p(t("hello")));
|
||||
const to = doc(h(1, t("hello")));
|
||||
const out = applyDiff(from, to);
|
||||
(0, vitest_1.expect)(out.eq(to)).toBe(true);
|
||||
(0, vitest_1.expect)(out.firstChild.type.name).toBe("heading");
|
||||
});
|
||||
(0, vitest_1.it)("reconstructs a combined structural + mark change", () => {
|
||||
// Several diff kinds at once (new block + italic run) still round-trips.
|
||||
const from = doc(p(t("alpha")));
|
||||
const to = doc(p(t("alpha")), p(t("beta", em)));
|
||||
const out = applyDiff(from, to);
|
||||
(0, vitest_1.expect)(out.eq(to)).toBe(true);
|
||||
});
|
||||
(0, vitest_1.it)("produces an empty step list for identical documents", () => {
|
||||
// No diff => no work; spurious steps would mean wasted/incorrect history.
|
||||
const from = doc(p(t("same")));
|
||||
const to = doc(p(t("same")));
|
||||
const tr = (0, recreateTransform_1.recreateTransform)(from, to);
|
||||
(0, vitest_1.expect)(tr.steps.length).toBe(0);
|
||||
(0, vitest_1.expect)(tr.doc.eq(to)).toBe(true);
|
||||
});
|
||||
(0, vitest_1.it)("round-trips with complexSteps:false (marks diffed as replaces)", () => {
|
||||
// With complexSteps off, mark changes are folded into replace steps rather
|
||||
// than dedicated mark steps — the result must still equal the target.
|
||||
const from = doc(p(t("hello")));
|
||||
const to = doc(p(t("hello", strong)));
|
||||
(0, vitest_1.expect)(applyDiff(from, to, { complexSteps: false }).eq(to)).toBe(true);
|
||||
});
|
||||
(0, vitest_1.it)("round-trips with wordDiffs:true (whole-word text diffing)", () => {
|
||||
// wordDiffs changes the granularity of the text diff, not the outcome.
|
||||
const from = doc(p(t("the quick brown fox")));
|
||||
const to = doc(p(t("the quick red fox")));
|
||||
(0, vitest_1.expect)(applyDiff(from, to, { wordDiffs: true }).eq(to)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.removeMarks = removeMarks;
|
||||
const transform_1 = require("@tiptap/pm/transform");
|
||||
function removeMarks(doc) {
|
||||
const tr = new transform_1.Transform(doc);
|
||||
tr.removeMark(0, doc.nodeSize - 2);
|
||||
return tr.doc;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.simplifyTransform = simplifyTransform;
|
||||
const transform_1 = require("@tiptap/pm/transform");
|
||||
const getReplaceStep_1 = require("./getReplaceStep");
|
||||
// join adjacent ReplaceSteps
|
||||
function simplifyTransform(tr) {
|
||||
if (!tr.steps.length) {
|
||||
return undefined;
|
||||
}
|
||||
const newTr = new transform_1.Transform(tr.docs[0]);
|
||||
const oldSteps = tr.steps.slice();
|
||||
while (oldSteps.length) {
|
||||
let step = oldSteps.shift();
|
||||
while (oldSteps.length && step.merge(oldSteps[0])) {
|
||||
const addedStep = oldSteps.shift();
|
||||
if (step instanceof transform_1.ReplaceStep && addedStep instanceof transform_1.ReplaceStep) {
|
||||
step = (0, getReplaceStep_1.getReplaceStep)(newTr.doc, addedStep.apply(step.apply(newTr.doc).doc).doc);
|
||||
}
|
||||
else {
|
||||
step = step.merge(addedStep);
|
||||
}
|
||||
}
|
||||
newTr.step(step);
|
||||
}
|
||||
return newTr;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
@@ -13,6 +13,7 @@ import { footnoteWarningsField } from "./lib/footnote-analyze.js";
|
||||
import { buildPageTree } from "./lib/tree.js";
|
||||
import { serializeDocmostMarkdown, parseDocmostMarkdown, } from "./lib/markdown-document.js";
|
||||
import { replaceNodeById, deleteNodeById, assertUnambiguousMatch, insertNodeRelative, buildOutline, getNodeByRef, readTable, insertTableRow, deleteTableRow, updateTableCell, } from "./lib/node-ops.js";
|
||||
import { searchInDoc } from "./lib/page-search.js";
|
||||
import { withPageLock } from "./lib/page-lock.js";
|
||||
import { applyTextEdits, } from "./lib/json-edit.js";
|
||||
import { getCollabToken, performLogin } from "./lib/auth-utils.js";
|
||||
@@ -872,6 +873,24 @@ export class DocmostClient {
|
||||
node: hit.node,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Find every occurrence of `query` on a page IN MEMORY, over the plain text of
|
||||
* each text container (reusing the same `getPageRaw` fetch as the other read
|
||||
* tools) — no server search endpoint, no whole-document round-trip through the
|
||||
* model. Returns `{ total, truncated, matches }`; each match carries a ref for
|
||||
* get_node/patch_node (the `#<index>` form resolves with get_node but NOT
|
||||
* patch_node — see SearchMatch.nodeId), plus the top-level block index and a
|
||||
* short context window used to build a unique text `selection` for
|
||||
* create_comment (create_comment has no nodeId param). The pure engine
|
||||
* (`searchInDoc`) owns the traversal, glue, the RE2 ReDoS-safe regex engine
|
||||
* and the empty-query / invalid-or-unsupported-regex errors.
|
||||
*/
|
||||
async searchInPage(pageId, query, opts = {}) {
|
||||
await this.ensureAuthenticated();
|
||||
const data = await this.getPageRaw(pageId);
|
||||
const result = searchInDoc(data.content ?? { type: "doc", content: [] }, query, opts);
|
||||
return { pageId, query, ...result };
|
||||
}
|
||||
/**
|
||||
* Read a table as a matrix. `tableRef` is `#<index>` (from get_outline) or a
|
||||
* block id of any node inside the table. Returns the cell texts plus a
|
||||
|
||||
+41
-13
@@ -27,10 +27,19 @@ const VERSION = packageJson.version;
|
||||
// --- Modern McpServer Implementation ---
|
||||
// Editing guide surfaced to MCP clients in the initialize result so they can
|
||||
// pick the right tool by intent and avoid resending whole documents.
|
||||
const SERVER_INSTRUCTIONS = "Docmost editing guide — choose the tool by intent: fix wording/typos/numbers (text inside blocks) -> edit_page_text (no node id needed). Change ONE block (paragraph/heading/callout/table cell/etc.) structurally -> patch_node (address by attrs.id from get_page_json). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Images -> insert_image (add an image from a web URL) / replace_image (swap an existing image for one from a web URL). New page -> create_page (Markdown). Bulk/structural rewrite or nodes without an id -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Rename a page (title only) -> rename_page. Read -> get_page (Markdown, lossy) or get_page_json (lossless ProseMirror with block ids). Comments -> create_comment (always inline; requires an EXACT selection — the contiguous text to anchor/highlight on; fails rather than leaving an unanchored comment), list_comments, update_comment, resolve_comment (resolve/reopen a thread, reversible — prefer over delete to close), delete_comment, check_new_comments. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Tip: read block ids via get_page_json, then use patch_node/insert_node/delete_node so you never resend the full document. " +
|
||||
"Complex/scripted rewrite (multiple coordinated edits, footnotes, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes. " +
|
||||
"Review what changed -> diff_page_versions (compare a historyId to current, or two history versions). See a page's saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). " +
|
||||
"Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
|
||||
//
|
||||
// MAINTENANCE RULE: when you ADD, RENAME, or REMOVE a tool (either an inline
|
||||
// server.registerTool(...) here or a spec in tool-specs.ts), you MUST update
|
||||
// this guide so the new tool is routed by intent. This is enforced by
|
||||
// test/unit/server-instructions.test.mjs, which fails when a registered tool
|
||||
// name is not mentioned below (see its EXCEPTIONS list for the rare opt-outs).
|
||||
// Exported for that test.
|
||||
export const SERVER_INSTRUCTIONS = "Docmost editing guide — choose the tool by intent.\n" +
|
||||
"READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#<index>\" for tables, which carry no id). Find every occurrence of a string/regex ON a page (and where each is) -> search_in_page, NOT block-by-block get_node — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> get_page (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" +
|
||||
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
|
||||
"PAGES: new -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
|
||||
"COMMENTS: create_comment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" +
|
||||
"HISTORY: review what changed -> diff_page_versions (a historyId vs current, or two versions). List saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
|
||||
// Helper to format JSON responses
|
||||
const jsonContent = (data) => ({
|
||||
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
||||
@@ -107,7 +116,9 @@ export function createDocmostMcpServer(config) {
|
||||
server.registerTool("get_page", {
|
||||
description: "Get page details with content converted to Markdown. The conversion is " +
|
||||
"LOSSY (block ids, exact table/callout structure are approximated); for a " +
|
||||
"lossless representation use get_page_json.",
|
||||
"lossless representation use get_page_json. Inline <span data-comment-id> " +
|
||||
"tags in the markdown are comment highlight anchors (also present for " +
|
||||
"RESOLVED threads) — treat them as markup, not page text.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1),
|
||||
},
|
||||
@@ -130,6 +141,15 @@ export function createDocmostMcpServer(config) {
|
||||
const result = await docmostClient.getNode(pageId, nodeId);
|
||||
return jsonContent(result);
|
||||
});
|
||||
// Tool: search_in_page
|
||||
registerShared(SHARED_TOOL_SPECS.searchInPage, async ({ pageId, query, regex, caseSensitive, limit }) => {
|
||||
const result = await docmostClient.searchInPage(pageId, query, {
|
||||
regex,
|
||||
caseSensitive,
|
||||
limit,
|
||||
});
|
||||
return jsonContent(result);
|
||||
});
|
||||
// Tool: table_get
|
||||
server.registerTool("table_get", {
|
||||
description: "Read a table as a matrix. Returns {rows, cols, cells (text[][]), " +
|
||||
@@ -204,7 +224,8 @@ export function createDocmostMcpServer(config) {
|
||||
});
|
||||
// Tool: create_page
|
||||
server.registerTool("create_page", {
|
||||
description: "Create a new page with content (automatically moves it to the correct hierarchy).",
|
||||
description: "Create a new page from Markdown in a space. Pass parentPageId to nest " +
|
||||
"it under a parent; omit it to create at the space root.",
|
||||
inputSchema: {
|
||||
title: z.string().min(1).describe("Title of the page"),
|
||||
content: z.string().min(1).describe("Markdown content"),
|
||||
@@ -426,7 +447,8 @@ export function createDocmostMcpServer(config) {
|
||||
// agent; this transport keeps the plain public-URL wording.
|
||||
server.registerTool("share_page", {
|
||||
description: "Make a page publicly accessible (idempotent) and return its public " +
|
||||
"URL. The URL format is <app>/share/<key>/p/<slugId>.",
|
||||
"URL. The URL format is <app>/share/<key>/p/<slugId>. This exposes the " +
|
||||
"page content to ANYONE with the URL — do it only when explicitly asked.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1).describe("ID of the page to share"),
|
||||
searchIndexing: z
|
||||
@@ -450,7 +472,7 @@ export function createDocmostMcpServer(config) {
|
||||
});
|
||||
// Tool: move_page
|
||||
server.registerTool("move_page", {
|
||||
description: "Move a page to a new parent (nesting) or root. Essential for organizing pages created via 'create_page'.",
|
||||
description: "Move a page under a new parent (nesting) or to the space root.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1),
|
||||
parentPageId: z
|
||||
@@ -486,7 +508,8 @@ export function createDocmostMcpServer(config) {
|
||||
});
|
||||
// Tool: delete_page
|
||||
server.registerTool("delete_page", {
|
||||
description: "Delete a single page by ID.",
|
||||
description: "Delete a single page by ID. SOFT delete only: the page is moved to " +
|
||||
"trash and can be restored; nothing is permanently deleted.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1),
|
||||
},
|
||||
@@ -501,7 +524,9 @@ export function createDocmostMcpServer(config) {
|
||||
// --- Comment tools (ported from upstream PR #3 by Max Nikitin) ---
|
||||
// Tool: list_comments
|
||||
server.registerTool("list_comments", {
|
||||
description: "List all comments on a page (paginated). Content is returned as Markdown.",
|
||||
description: "List ALL comments on a page in one call (pagination is handled " +
|
||||
"internally), including RESOLVED threads — filter by resolvedAt when you " +
|
||||
"need only open ones. Content is returned as Markdown.",
|
||||
inputSchema: {
|
||||
pageId: z.string().describe("ID of the page"),
|
||||
},
|
||||
@@ -652,8 +677,9 @@ export function createDocmostMcpServer(config) {
|
||||
// different schema (limit 1-20); this transport is a plain REST full-text search
|
||||
// (limit up to 100). Different behaviour AND schema, so kept per-layer.
|
||||
server.registerTool("search", {
|
||||
description: "Search for pages and content. Results are bounded by `limit` " +
|
||||
"(default applied by the client, max 100).",
|
||||
description: "Full-text search for pages and content across the whole workspace. " +
|
||||
"Results are bounded by `limit` (1-100; when omitted the server applies " +
|
||||
"its own default).",
|
||||
inputSchema: {
|
||||
query: z.string().min(1).describe("Search query"),
|
||||
limit: z
|
||||
@@ -703,7 +729,9 @@ export function createDocmostMcpServer(config) {
|
||||
"insertInlineFootnote(doc, {anchorText, text}) (author-inline footnote: " +
|
||||
"marker + dedup'd definition, list derived). Footnote convention: markers are " +
|
||||
"plain '[N]' text in the body; the notes are an orderedList under a " +
|
||||
"heading whose text is 'Примечания переводчика'. The transform runs " +
|
||||
"heading whose text is 'Примечания переводчика' (that is only the DEFAULT " +
|
||||
"notesHeading — pass the notesHeading option to the helpers to use a " +
|
||||
"heading matching the page's language). The transform runs " +
|
||||
"sandboxed (no require/process/fs/network, 5s timeout) and must return a " +
|
||||
"{type:'doc'} node.",
|
||||
inputSchema: {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Pure, network-free in-page search over a ProseMirror/TipTap document tree.
|
||||
*
|
||||
* `searchInDoc(doc, query, opts)` finds every occurrence of a literal substring
|
||||
* (default) or a regular expression across the page's TEXT CONTAINERS and
|
||||
* reports WHERE each match is — the container's ref (for get_node/patch_node;
|
||||
* see the SearchMatch.nodeId note for the `#<index>` caveat), the top-level
|
||||
* block index, and a short context window around the hit. It never touches the
|
||||
* network, the DB, or the schema mirror; like `comment-anchor.ts` it is
|
||||
* isolated-testable.
|
||||
*
|
||||
* REGEX ENGINE: with `regex:true` the pattern is compiled with RE2 (Google's
|
||||
* linear-time engine), NOT the JS `RegExp`. RE2 has no backtracking, so a
|
||||
* catastrophic pattern (e.g. `(a+)+$`) can never wedge the shared event loop —
|
||||
* it runs in linear time. The trade-off is that RE2 does not support the
|
||||
* backtracking-only features lookaround (`(?=…)`, `(?<=…)`) and backreferences
|
||||
* (`\1`); such a pattern is rejected up front with a clear tool error (see
|
||||
* searchInDoc) rather than being run, which is the desired behaviour — a clear
|
||||
* error the agent can fix beats a server hang.
|
||||
*
|
||||
* WHY plain text (not markdown): each container's inline text is glued into ONE
|
||||
* string via `blockPlainText`, so a match survives inline-mark boundaries
|
||||
* (bold/italic/link splits that fracture a run like "т.е." into several text
|
||||
* nodes) and comment-anchor spans never clutter the haystack.
|
||||
*
|
||||
* The SEARCH UNIT is a text container: a node whose direct children include
|
||||
* text nodes (a paragraph/heading, or the paragraph inside a table cell / list
|
||||
* item). ProseMirror keeps block vs. inline content exclusive, so a container
|
||||
* never nests another container — the walk reaches each cell/item's own text and
|
||||
* the context window is naturally scoped to that specific cell/item, not the
|
||||
* whole top-level block's glued text.
|
||||
*/
|
||||
import RE2 from "re2";
|
||||
import { blockPlainText } from "./node-ops.js";
|
||||
/** True if `value` is a non-null plain object (and not an array). */
|
||||
function isObject(value) {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
/**
|
||||
* A text container is a node with a `content` array holding at least one text
|
||||
* node (a child with a string `text`). These are the paragraphs/headings whose
|
||||
* glued inline text we search.
|
||||
*/
|
||||
function isTextContainer(node) {
|
||||
return (isObject(node) &&
|
||||
Array.isArray(node.content) &&
|
||||
node.content.some((c) => isObject(c) && typeof c.text === "string"));
|
||||
}
|
||||
// Result-size defaults/ceiling.
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 200;
|
||||
// Context window on each side of a match.
|
||||
const CONTEXT = 40;
|
||||
// Cheap sanity cap on the query/pattern length. ReDoS is handled structurally
|
||||
// by the RE2 engine (linear-time, no backtracking — see the module doc), so we
|
||||
// no longer truncate the per-container text: RE2 scans it in linear time and a
|
||||
// cap could silently drop real matches past it. This just rejects an absurdly
|
||||
// long pattern early with a clear error.
|
||||
const MAX_PATTERN_LENGTH = 1000;
|
||||
/** Clamp the requested limit into [1, MAX_LIMIT], defaulting when absent. */
|
||||
function resolveLimit(limit) {
|
||||
const n = typeof limit === "number" && Number.isFinite(limit) ? limit : DEFAULT_LIMIT;
|
||||
return Math.min(MAX_LIMIT, Math.max(1, Math.floor(n)));
|
||||
}
|
||||
/**
|
||||
* Yield the [start, length] of every occurrence of the engine in `text`, in
|
||||
* order. A literal engine uses indexOf (case-folded when requested); a regex
|
||||
* engine uses a global RE2 regex (RE2 extends `RegExp`, so `.exec` advances
|
||||
* `lastIndex` exactly like the native engine). Zero-length regex matches (e.g.
|
||||
* `\b`, `a*`) are SKIPPED and lastIndex is advanced, so a pattern that can match
|
||||
* the empty string cannot flood the results or spin forever.
|
||||
*/
|
||||
function* eachMatch(text, query, re, caseSensitive) {
|
||||
if (re) {
|
||||
re.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = re.exec(text)) != null) {
|
||||
const len = m[0].length;
|
||||
if (len === 0) {
|
||||
// Empty match: advance past this position and do not record it.
|
||||
re.lastIndex = m.index + 1;
|
||||
continue;
|
||||
}
|
||||
yield [m.index, len];
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Literal engine. For case-insensitive search, fold BOTH sides only to locate
|
||||
// the indices; the reported match/context are always sliced from the original
|
||||
// text so the caller gets the real casing (needed to build a unique selection).
|
||||
const haystack = caseSensitive ? text : text.toLowerCase();
|
||||
const needle = caseSensitive ? query : query.toLowerCase();
|
||||
const len = needle.length;
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const idx = haystack.indexOf(needle, from);
|
||||
if (idx === -1)
|
||||
return;
|
||||
yield [idx, len];
|
||||
from = idx + len;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Search a ProseMirror document for `query` and return `{ total, truncated,
|
||||
* matches }`. `total` counts EVERY occurrence (even beyond the limit) and
|
||||
* `truncated` flags when the returned list was capped — nothing is silently
|
||||
* dropped.
|
||||
*
|
||||
* Throws a clear, model-actionable error (never a generic failure) on: an
|
||||
* empty/whitespace-only query, an over-long pattern, or — with `regex:true` — a
|
||||
* pattern RE2 rejects (invalid syntax, or the unsupported lookaround/
|
||||
* backreference features), so the agent can fix its input.
|
||||
*/
|
||||
export function searchInDoc(doc, query, opts = {}) {
|
||||
// --- edge-case guards (fail loudly so the agent can correct the call) ---
|
||||
if (typeof query !== "string" || query.trim().length === 0) {
|
||||
throw new Error("search_in_page: query is empty — pass the text (or regex) to look for.");
|
||||
}
|
||||
if (query.length > MAX_PATTERN_LENGTH) {
|
||||
throw new Error(`search_in_page: query is too long (${query.length} chars; max ${MAX_PATTERN_LENGTH}). Shorten the search text/pattern.`);
|
||||
}
|
||||
const caseSensitive = opts.caseSensitive === true;
|
||||
const limit = resolveLimit(opts.limit);
|
||||
// Compile the pattern up front with RE2 (linear-time, ReDoS-safe) so a bad
|
||||
// pattern is a clean tool error rather than a failure deep in the traversal —
|
||||
// and so a catastrophic-backtracking pattern can never wedge the event loop.
|
||||
// RE2 throws both on syntactically invalid input AND on backtracking-only
|
||||
// features it does not implement (lookaround, backreferences); both map to the
|
||||
// same actionable error so the agent rewrites the pattern.
|
||||
let re = null;
|
||||
if (opts.regex === true) {
|
||||
try {
|
||||
re = new RE2(query, caseSensitive ? "g" : "gi");
|
||||
}
|
||||
catch (e) {
|
||||
throw new Error(`search_in_page: invalid or unsupported regular expression: ${e instanceof Error ? e.message : String(e)} — RE2 does not support lookaround ((?=…)/(?<=…)) or backreferences (\\1); rewrite the pattern without them.`);
|
||||
}
|
||||
}
|
||||
const matches = [];
|
||||
let total = 0;
|
||||
const topLevel = isObject(doc) && Array.isArray(doc.content) ? doc.content : [];
|
||||
// Descend a top-level block, collecting matches from every text container
|
||||
// within it. blockIndex/topRef stay pinned to the enclosing top-level block.
|
||||
const descend = (node, blockIndex, topRef) => {
|
||||
if (!isObject(node))
|
||||
return;
|
||||
if (isTextContainer(node)) {
|
||||
// Glue this container's inline text into one string (mark-safe). No length
|
||||
// cap: RE2 scans it in linear time (no ReDoS) and the whole document is
|
||||
// already in memory, so truncating would only risk dropping real matches
|
||||
// in a very long container.
|
||||
const text = blockPlainText(node);
|
||||
// The container's own id addresses it verbatim in get_node/patch_node; a
|
||||
// container with no id (e.g. a table-cell paragraph) falls back to the
|
||||
// top-level block's #<index> (readable via get_node, but not patchable —
|
||||
// see the SearchMatch.nodeId note).
|
||||
const id = isObject(node.attrs) && typeof node.attrs.id === "string" && node.attrs.id.length > 0
|
||||
? node.attrs.id
|
||||
: topRef;
|
||||
for (const [idx, len] of eachMatch(text, query, re, caseSensitive)) {
|
||||
total++;
|
||||
if (matches.length < limit) {
|
||||
matches.push({
|
||||
nodeId: id,
|
||||
blockIndex,
|
||||
type: node.type,
|
||||
before: text.slice(Math.max(0, idx - CONTEXT), idx),
|
||||
match: text.slice(idx, idx + len),
|
||||
after: text.slice(idx + len, idx + len + CONTEXT),
|
||||
});
|
||||
}
|
||||
}
|
||||
// A text container holds inline content only — no nested containers to
|
||||
// recurse into.
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content)
|
||||
descend(child, blockIndex, topRef);
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < topLevel.length; i++) {
|
||||
descend(topLevel[i], i, `#${i}`);
|
||||
}
|
||||
return { total, truncated: total > matches.length, matches };
|
||||
}
|
||||
@@ -13,6 +13,11 @@
|
||||
// diverge on purpose (security guardrails, tuned UX, "Reversible" framing on
|
||||
// some write tools, different limits, hybrid-RRF search, etc.) stay defined
|
||||
// per-layer and are NOT represented here.
|
||||
//
|
||||
// MAINTENANCE RULE: adding, renaming, or removing a spec here (or an inline
|
||||
// registerTool in index.ts) REQUIRES updating SERVER_INSTRUCTIONS in
|
||||
// packages/mcp/src/index.ts — the intent-routing guide MCP clients receive on
|
||||
// initialize. Enforced by test/unit/server-instructions.test.mjs.
|
||||
export const SHARED_TOOL_SPECS = {
|
||||
// --- no-argument read tools ---
|
||||
getWorkspace: {
|
||||
@@ -69,12 +74,61 @@ export const SHARED_TOOL_SPECS = {
|
||||
nodeId: z.string().min(1),
|
||||
}),
|
||||
},
|
||||
// --- in-page occurrence search (client-side, over ProseMirror plain text) ---
|
||||
searchInPage: {
|
||||
mcpName: 'search_in_page',
|
||||
inAppKey: 'searchInPage',
|
||||
description: 'Find every occurrence of a string (or regex) INSIDE one page and get ' +
|
||||
'WHERE each is — instead of pulling blocks one-by-one with get_node. ' +
|
||||
'Searches the plain text of each text block/cell (marks glued, so a match ' +
|
||||
'survives bold/italic/link splits; comment anchors do not interfere). ' +
|
||||
'Returns { total, truncated, matches:[{ nodeId, blockIndex, type, before, ' +
|
||||
'match, after }] }: `nodeId` is the block id (or "#<index>" for ' +
|
||||
'table/cell content) — pass it to get_node/patch_node (the "#<index>" ' +
|
||||
'form resolves with get_node but NOT patch_node, which only accepts a real ' +
|
||||
'block id). To anchor a comment, do NOT pass nodeId to create_comment (it ' +
|
||||
'has no nodeId param); build a UNIQUE text selection from before+match+' +
|
||||
'after and pass it as create_comment\'s `selection`. `blockIndex` is the ' +
|
||||
'get_outline index; `before`/`after` give ~40 chars of context to build ' +
|
||||
'that unique selection. `total` counts all ' +
|
||||
'hits and `truncated` is true when more than `limit` were found (nothing ' +
|
||||
'is silently dropped). Default is a literal, case-INSENSITIVE substring; ' +
|
||||
'set regex:true for an RE2 regular expression (linear-time, ReDoS-safe: ' +
|
||||
'char classes, word boundaries, anchors and quantifiers work; lookaround ' +
|
||||
'(?=…)/(?<=…) and backreferences \\1 are NOT supported) and ' +
|
||||
'caseSensitive:true to match case. Ideal for systematic ' +
|
||||
'editorial sweeps (unquoted "ё", straight quotes, "т.е.", stray units). An ' +
|
||||
'invalid regex or an empty query returns a clear error to fix.',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('ID of the page to search'),
|
||||
query: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('The text to find (a literal substring, or a regex when regex:true)'),
|
||||
regex: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Treat query as an RE2 regular expression — linear-time, ReDoS-safe; ' +
|
||||
'no lookaround or backreferences (default false).'),
|
||||
caseSensitive: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Case-sensitive matching (default false).'),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.optional()
|
||||
.describe('Max matches to RETURN (default 50, max 200); total is always reported.'),
|
||||
}),
|
||||
},
|
||||
// --- node delete ---
|
||||
deleteNode: {
|
||||
mcpName: 'delete_node',
|
||||
inAppKey: 'deleteNode',
|
||||
description: 'Remove a single block by its attrs.id (from the page-JSON view) WITHOUT ' +
|
||||
'resending the whole document.',
|
||||
description: 'Remove a single block by its attrs.id (from the page outline or ' +
|
||||
'page-JSON view) WITHOUT resending the whole document.',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1),
|
||||
nodeId: z.string().min(1),
|
||||
@@ -94,7 +148,8 @@ export const SHARED_TOOL_SPECS = {
|
||||
inAppKey: 'patchNode',
|
||||
description: 'Replace a single content block identified by its attrs.id with a new ' +
|
||||
'ProseMirror node, WITHOUT resending the whole document; the replacement ' +
|
||||
'keeps the same node id. Get the block id from the page-JSON view, then ' +
|
||||
'keeps the same node id. Get the block id from the page outline (cheap) ' +
|
||||
'or the page-JSON view, then ' +
|
||||
'pass a ProseMirror node to put in its place. Example node: a paragraph ' +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
||||
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
||||
@@ -123,7 +178,8 @@ export const SHARED_TOOL_SPECS = {
|
||||
description: 'Insert a block before/after another block (by attrs.id or anchor text) ' +
|
||||
'or append it at the end (top level). For before/after you MUST provide ' +
|
||||
'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' +
|
||||
'page-JSON view. Avoids resending the whole document. Can also insert ' +
|
||||
'page outline or the page-JSON view. Avoids resending the whole document. ' +
|
||||
'Can also insert ' +
|
||||
'table structure: to add a tableRow, pass a tableRow node with position ' +
|
||||
'before/after and anchor INSIDE the target table — anchorNodeId of any ' +
|
||||
'block/cell in it, or anchorText matching the table; to add a ' +
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"form-data": "^4.0.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"marked": "^17.0.1",
|
||||
"re2": "^1.21.0",
|
||||
"ws": "^8.19.0",
|
||||
"y-prosemirror": "1.3.7",
|
||||
"yjs": "^13.6.29",
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
deleteTableRow,
|
||||
updateTableCell,
|
||||
} from "./lib/node-ops.js";
|
||||
import { searchInDoc, SearchOptions } from "./lib/page-search.js";
|
||||
import { withPageLock } from "./lib/page-lock.js";
|
||||
import {
|
||||
applyTextEdits,
|
||||
@@ -1093,6 +1094,29 @@ export class DocmostClient {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every occurrence of `query` on a page IN MEMORY, over the plain text of
|
||||
* each text container (reusing the same `getPageRaw` fetch as the other read
|
||||
* tools) — no server search endpoint, no whole-document round-trip through the
|
||||
* model. Returns `{ total, truncated, matches }`; each match carries a ref for
|
||||
* get_node/patch_node (the `#<index>` form resolves with get_node but NOT
|
||||
* patch_node — see SearchMatch.nodeId), plus the top-level block index and a
|
||||
* short context window used to build a unique text `selection` for
|
||||
* create_comment (create_comment has no nodeId param). The pure engine
|
||||
* (`searchInDoc`) owns the traversal, glue, the RE2 ReDoS-safe regex engine
|
||||
* and the empty-query / invalid-or-unsupported-regex errors.
|
||||
*/
|
||||
async searchInPage(pageId: string, query: string, opts: SearchOptions = {}) {
|
||||
await this.ensureAuthenticated();
|
||||
const data = await this.getPageRaw(pageId);
|
||||
const result = searchInDoc(
|
||||
data.content ?? { type: "doc", content: [] },
|
||||
query,
|
||||
opts,
|
||||
);
|
||||
return { pageId, query, ...result };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a table as a matrix. `tableRef` is `#<index>` (from get_outline) or a
|
||||
* block id of any node inside the table. Returns the cell texts plus a
|
||||
|
||||
+47
-14
@@ -37,11 +37,20 @@ const VERSION = packageJson.version;
|
||||
|
||||
// Editing guide surfaced to MCP clients in the initialize result so they can
|
||||
// pick the right tool by intent and avoid resending whole documents.
|
||||
const SERVER_INSTRUCTIONS =
|
||||
"Docmost editing guide — choose the tool by intent: fix wording/typos/numbers (text inside blocks) -> edit_page_text (no node id needed). Change ONE block (paragraph/heading/callout/table cell/etc.) structurally -> patch_node (address by attrs.id from get_page_json). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Images -> insert_image (add an image from a web URL) / replace_image (swap an existing image for one from a web URL). New page -> create_page (Markdown). Bulk/structural rewrite or nodes without an id -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Rename a page (title only) -> rename_page. Read -> get_page (Markdown, lossy) or get_page_json (lossless ProseMirror with block ids). Comments -> create_comment (always inline; requires an EXACT selection — the contiguous text to anchor/highlight on; fails rather than leaving an unanchored comment), list_comments, update_comment, resolve_comment (resolve/reopen a thread, reversible — prefer over delete to close), delete_comment, check_new_comments. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Tip: read block ids via get_page_json, then use patch_node/insert_node/delete_node so you never resend the full document. " +
|
||||
"Complex/scripted rewrite (multiple coordinated edits, footnotes, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes. " +
|
||||
"Review what changed -> diff_page_versions (compare a historyId to current, or two history versions). See a page's saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). " +
|
||||
"Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
|
||||
//
|
||||
// MAINTENANCE RULE: when you ADD, RENAME, or REMOVE a tool (either an inline
|
||||
// server.registerTool(...) here or a spec in tool-specs.ts), you MUST update
|
||||
// this guide so the new tool is routed by intent. This is enforced by
|
||||
// test/unit/server-instructions.test.mjs, which fails when a registered tool
|
||||
// name is not mentioned below (see its EXCEPTIONS list for the rare opt-outs).
|
||||
// Exported for that test.
|
||||
export const SERVER_INSTRUCTIONS =
|
||||
"Docmost editing guide — choose the tool by intent.\n" +
|
||||
"READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#<index>\" for tables, which carry no id). Find every occurrence of a string/regex ON a page (and where each is) -> search_in_page, NOT block-by-block get_node — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> get_page (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" +
|
||||
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
|
||||
"PAGES: new -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
|
||||
"COMMENTS: create_comment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" +
|
||||
"HISTORY: review what changed -> diff_page_versions (a historyId vs current, or two versions). List saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
|
||||
|
||||
// Helper to format JSON responses
|
||||
const jsonContent = (data: any) => ({
|
||||
@@ -147,7 +156,9 @@ server.registerTool(
|
||||
description:
|
||||
"Get page details with content converted to Markdown. The conversion is " +
|
||||
"LOSSY (block ids, exact table/callout structure are approximated); for a " +
|
||||
"lossless representation use get_page_json.",
|
||||
"lossless representation use get_page_json. Inline <span data-comment-id> " +
|
||||
"tags in the markdown are comment highlight anchors (also present for " +
|
||||
"RESOLVED threads) — treat them as markup, not page text.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1),
|
||||
},
|
||||
@@ -176,6 +187,19 @@ registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => {
|
||||
return jsonContent(result);
|
||||
});
|
||||
|
||||
// Tool: search_in_page
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.searchInPage,
|
||||
async ({ pageId, query, regex, caseSensitive, limit }) => {
|
||||
const result = await docmostClient.searchInPage(pageId, query, {
|
||||
regex,
|
||||
caseSensitive,
|
||||
limit,
|
||||
});
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: table_get
|
||||
server.registerTool(
|
||||
"table_get",
|
||||
@@ -288,7 +312,8 @@ server.registerTool(
|
||||
"create_page",
|
||||
{
|
||||
description:
|
||||
"Create a new page with content (automatically moves it to the correct hierarchy).",
|
||||
"Create a new page from Markdown in a space. Pass parentPageId to nest " +
|
||||
"it under a parent; omit it to create at the space root.",
|
||||
inputSchema: {
|
||||
title: z.string().min(1).describe("Title of the page"),
|
||||
content: z.string().min(1).describe("Markdown content"),
|
||||
@@ -587,7 +612,8 @@ server.registerTool(
|
||||
{
|
||||
description:
|
||||
"Make a page publicly accessible (idempotent) and return its public " +
|
||||
"URL. The URL format is <app>/share/<key>/p/<slugId>.",
|
||||
"URL. The URL format is <app>/share/<key>/p/<slugId>. This exposes the " +
|
||||
"page content to ANYONE with the URL — do it only when explicitly asked.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1).describe("ID of the page to share"),
|
||||
searchIndexing: z
|
||||
@@ -619,7 +645,7 @@ server.registerTool(
|
||||
"move_page",
|
||||
{
|
||||
description:
|
||||
"Move a page to a new parent (nesting) or root. Essential for organizing pages created via 'create_page'.",
|
||||
"Move a page under a new parent (nesting) or to the space root.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1),
|
||||
parentPageId: z
|
||||
@@ -675,7 +701,9 @@ server.registerTool(
|
||||
server.registerTool(
|
||||
"delete_page",
|
||||
{
|
||||
description: "Delete a single page by ID.",
|
||||
description:
|
||||
"Delete a single page by ID. SOFT delete only: the page is moved to " +
|
||||
"trash and can be restored; nothing is permanently deleted.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1),
|
||||
},
|
||||
@@ -697,7 +725,9 @@ server.registerTool(
|
||||
"list_comments",
|
||||
{
|
||||
description:
|
||||
"List all comments on a page (paginated). Content is returned as Markdown.",
|
||||
"List ALL comments on a page in one call (pagination is handled " +
|
||||
"internally), including RESOLVED threads — filter by resolvedAt when you " +
|
||||
"need only open ones. Content is returned as Markdown.",
|
||||
inputSchema: {
|
||||
pageId: z.string().describe("ID of the page"),
|
||||
},
|
||||
@@ -913,8 +943,9 @@ server.registerTool(
|
||||
"search",
|
||||
{
|
||||
description:
|
||||
"Search for pages and content. Results are bounded by `limit` " +
|
||||
"(default applied by the client, max 100).",
|
||||
"Full-text search for pages and content across the whole workspace. " +
|
||||
"Results are bounded by `limit` (1-100; when omitted the server applies " +
|
||||
"its own default).",
|
||||
inputSchema: {
|
||||
query: z.string().min(1).describe("Search query"),
|
||||
limit: z
|
||||
@@ -970,7 +1001,9 @@ server.registerTool(
|
||||
"insertInlineFootnote(doc, {anchorText, text}) (author-inline footnote: " +
|
||||
"marker + dedup'd definition, list derived). Footnote convention: markers are " +
|
||||
"plain '[N]' text in the body; the notes are an orderedList under a " +
|
||||
"heading whose text is 'Примечания переводчика'. The transform runs " +
|
||||
"heading whose text is 'Примечания переводчика' (that is only the DEFAULT " +
|
||||
"notesHeading — pass the notesHeading option to the helpers to use a " +
|
||||
"heading matching the page's language). The transform runs " +
|
||||
"sandboxed (no require/process/fs/network, 5s timeout) and must return a " +
|
||||
"{type:'doc'} node.",
|
||||
inputSchema: {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Pure, network-free in-page search over a ProseMirror/TipTap document tree.
|
||||
*
|
||||
* `searchInDoc(doc, query, opts)` finds every occurrence of a literal substring
|
||||
* (default) or a regular expression across the page's TEXT CONTAINERS and
|
||||
* reports WHERE each match is — the container's ref (for get_node/patch_node;
|
||||
* see the SearchMatch.nodeId note for the `#<index>` caveat), the top-level
|
||||
* block index, and a short context window around the hit. It never touches the
|
||||
* network, the DB, or the schema mirror; like `comment-anchor.ts` it is
|
||||
* isolated-testable.
|
||||
*
|
||||
* REGEX ENGINE: with `regex:true` the pattern is compiled with RE2 (Google's
|
||||
* linear-time engine), NOT the JS `RegExp`. RE2 has no backtracking, so a
|
||||
* catastrophic pattern (e.g. `(a+)+$`) can never wedge the shared event loop —
|
||||
* it runs in linear time. The trade-off is that RE2 does not support the
|
||||
* backtracking-only features lookaround (`(?=…)`, `(?<=…)`) and backreferences
|
||||
* (`\1`); such a pattern is rejected up front with a clear tool error (see
|
||||
* searchInDoc) rather than being run, which is the desired behaviour — a clear
|
||||
* error the agent can fix beats a server hang.
|
||||
*
|
||||
* WHY plain text (not markdown): each container's inline text is glued into ONE
|
||||
* string via `blockPlainText`, so a match survives inline-mark boundaries
|
||||
* (bold/italic/link splits that fracture a run like "т.е." into several text
|
||||
* nodes) and comment-anchor spans never clutter the haystack.
|
||||
*
|
||||
* The SEARCH UNIT is a text container: a node whose direct children include
|
||||
* text nodes (a paragraph/heading, or the paragraph inside a table cell / list
|
||||
* item). ProseMirror keeps block vs. inline content exclusive, so a container
|
||||
* never nests another container — the walk reaches each cell/item's own text and
|
||||
* the context window is naturally scoped to that specific cell/item, not the
|
||||
* whole top-level block's glued text.
|
||||
*/
|
||||
|
||||
import RE2 from "re2";
|
||||
|
||||
import { blockPlainText } from "./node-ops.js";
|
||||
|
||||
/** An RE2 regex instance (RE2 extends `RegExp`, so it is usable as one). */
|
||||
type Re2Regex = InstanceType<typeof RE2>;
|
||||
|
||||
/** True if `value` is a non-null plain object (and not an array). */
|
||||
function isObject(value: any): value is Record<string, any> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* A text container is a node with a `content` array holding at least one text
|
||||
* node (a child with a string `text`). These are the paragraphs/headings whose
|
||||
* glued inline text we search.
|
||||
*/
|
||||
function isTextContainer(node: any): boolean {
|
||||
return (
|
||||
isObject(node) &&
|
||||
Array.isArray(node.content) &&
|
||||
node.content.some((c: any) => isObject(c) && typeof c.text === "string")
|
||||
);
|
||||
}
|
||||
|
||||
/** Options controlling the search engine and result size. */
|
||||
export interface SearchOptions {
|
||||
/** Treat `query` as a RegExp instead of a literal substring (default false). */
|
||||
regex?: boolean;
|
||||
/** Case-sensitive matching (default false). */
|
||||
caseSensitive?: boolean;
|
||||
/** Max matches to RETURN (default 50, clamped to [1, 200]); total is unbounded. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** One located occurrence. */
|
||||
export interface SearchMatch {
|
||||
/**
|
||||
* The container's ref, for addressing the block with get_node/patch_node: its
|
||||
* `attrs.id` when it has one, otherwise `#<topLevelIndex>` of the nearest
|
||||
* top-level block. Table-cell/list-item paragraphs that carry no id fall back
|
||||
* to the `#<index>` form.
|
||||
*
|
||||
* CAVEAT: the `#<index>` form is accepted by get_node (getNodeByRef resolves
|
||||
* it by top-level index) but NOT by patch_node (replaceNodeById resolves only
|
||||
* by `attrs.id`), so id-less table/cell content can be READ by this ref but
|
||||
* not PATCHED by it.
|
||||
*
|
||||
* To anchor a comment, do NOT pass this ref to create_comment — it has no
|
||||
* nodeId parameter. A top-level comment needs an exact-text `selection` that
|
||||
* occurs once on the page (it fails if the text isn't found), so build a
|
||||
* UNIQUE `selection` from before+match+after and pass THAT as create_comment's
|
||||
* `selection`.
|
||||
*/
|
||||
nodeId: string;
|
||||
/** The top-level block index (as in get_outline). */
|
||||
blockIndex: number;
|
||||
/** The container node's type (paragraph/heading/...). */
|
||||
type: string | undefined;
|
||||
/** ~40 chars of context immediately before the match (from THIS container). */
|
||||
before: string;
|
||||
/** The matched text. */
|
||||
match: string;
|
||||
/** ~40 chars of context immediately after the match (from THIS container). */
|
||||
after: string;
|
||||
}
|
||||
|
||||
/** The search result. `truncated` is true when `total > matches.length`. */
|
||||
export interface SearchResult {
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
matches: SearchMatch[];
|
||||
}
|
||||
|
||||
// Result-size defaults/ceiling.
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
// Context window on each side of a match.
|
||||
const CONTEXT = 40;
|
||||
|
||||
// Cheap sanity cap on the query/pattern length. ReDoS is handled structurally
|
||||
// by the RE2 engine (linear-time, no backtracking — see the module doc), so we
|
||||
// no longer truncate the per-container text: RE2 scans it in linear time and a
|
||||
// cap could silently drop real matches past it. This just rejects an absurdly
|
||||
// long pattern early with a clear error.
|
||||
const MAX_PATTERN_LENGTH = 1000;
|
||||
|
||||
/** Clamp the requested limit into [1, MAX_LIMIT], defaulting when absent. */
|
||||
function resolveLimit(limit: number | undefined): number {
|
||||
const n = typeof limit === "number" && Number.isFinite(limit) ? limit : DEFAULT_LIMIT;
|
||||
return Math.min(MAX_LIMIT, Math.max(1, Math.floor(n)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield the [start, length] of every occurrence of the engine in `text`, in
|
||||
* order. A literal engine uses indexOf (case-folded when requested); a regex
|
||||
* engine uses a global RE2 regex (RE2 extends `RegExp`, so `.exec` advances
|
||||
* `lastIndex` exactly like the native engine). Zero-length regex matches (e.g.
|
||||
* `\b`, `a*`) are SKIPPED and lastIndex is advanced, so a pattern that can match
|
||||
* the empty string cannot flood the results or spin forever.
|
||||
*/
|
||||
function* eachMatch(
|
||||
text: string,
|
||||
query: string,
|
||||
re: Re2Regex | null,
|
||||
caseSensitive: boolean,
|
||||
): Generator<[number, number]> {
|
||||
if (re) {
|
||||
re.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) != null) {
|
||||
const len = m[0].length;
|
||||
if (len === 0) {
|
||||
// Empty match: advance past this position and do not record it.
|
||||
re.lastIndex = m.index + 1;
|
||||
continue;
|
||||
}
|
||||
yield [m.index, len];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Literal engine. For case-insensitive search, fold BOTH sides only to locate
|
||||
// the indices; the reported match/context are always sliced from the original
|
||||
// text so the caller gets the real casing (needed to build a unique selection).
|
||||
const haystack = caseSensitive ? text : text.toLowerCase();
|
||||
const needle = caseSensitive ? query : query.toLowerCase();
|
||||
const len = needle.length;
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const idx = haystack.indexOf(needle, from);
|
||||
if (idx === -1) return;
|
||||
yield [idx, len];
|
||||
from = idx + len;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search a ProseMirror document for `query` and return `{ total, truncated,
|
||||
* matches }`. `total` counts EVERY occurrence (even beyond the limit) and
|
||||
* `truncated` flags when the returned list was capped — nothing is silently
|
||||
* dropped.
|
||||
*
|
||||
* Throws a clear, model-actionable error (never a generic failure) on: an
|
||||
* empty/whitespace-only query, an over-long pattern, or — with `regex:true` — a
|
||||
* pattern RE2 rejects (invalid syntax, or the unsupported lookaround/
|
||||
* backreference features), so the agent can fix its input.
|
||||
*/
|
||||
export function searchInDoc(
|
||||
doc: any,
|
||||
query: string,
|
||||
opts: SearchOptions = {},
|
||||
): SearchResult {
|
||||
// --- edge-case guards (fail loudly so the agent can correct the call) ---
|
||||
if (typeof query !== "string" || query.trim().length === 0) {
|
||||
throw new Error(
|
||||
"search_in_page: query is empty — pass the text (or regex) to look for.",
|
||||
);
|
||||
}
|
||||
if (query.length > MAX_PATTERN_LENGTH) {
|
||||
throw new Error(
|
||||
`search_in_page: query is too long (${query.length} chars; max ${MAX_PATTERN_LENGTH}). Shorten the search text/pattern.`,
|
||||
);
|
||||
}
|
||||
|
||||
const caseSensitive = opts.caseSensitive === true;
|
||||
const limit = resolveLimit(opts.limit);
|
||||
|
||||
// Compile the pattern up front with RE2 (linear-time, ReDoS-safe) so a bad
|
||||
// pattern is a clean tool error rather than a failure deep in the traversal —
|
||||
// and so a catastrophic-backtracking pattern can never wedge the event loop.
|
||||
// RE2 throws both on syntactically invalid input AND on backtracking-only
|
||||
// features it does not implement (lookaround, backreferences); both map to the
|
||||
// same actionable error so the agent rewrites the pattern.
|
||||
let re: Re2Regex | null = null;
|
||||
if (opts.regex === true) {
|
||||
try {
|
||||
re = new RE2(query, caseSensitive ? "g" : "gi");
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`search_in_page: invalid or unsupported regular expression: ${
|
||||
e instanceof Error ? e.message : String(e)
|
||||
} — RE2 does not support lookaround ((?=…)/(?<=…)) or backreferences (\\1); rewrite the pattern without them.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const matches: SearchMatch[] = [];
|
||||
let total = 0;
|
||||
|
||||
const topLevel =
|
||||
isObject(doc) && Array.isArray(doc.content) ? doc.content : [];
|
||||
|
||||
// Descend a top-level block, collecting matches from every text container
|
||||
// within it. blockIndex/topRef stay pinned to the enclosing top-level block.
|
||||
const descend = (node: any, blockIndex: number, topRef: string): void => {
|
||||
if (!isObject(node)) return;
|
||||
|
||||
if (isTextContainer(node)) {
|
||||
// Glue this container's inline text into one string (mark-safe). No length
|
||||
// cap: RE2 scans it in linear time (no ReDoS) and the whole document is
|
||||
// already in memory, so truncating would only risk dropping real matches
|
||||
// in a very long container.
|
||||
const text = blockPlainText(node);
|
||||
|
||||
// The container's own id addresses it verbatim in get_node/patch_node; a
|
||||
// container with no id (e.g. a table-cell paragraph) falls back to the
|
||||
// top-level block's #<index> (readable via get_node, but not patchable —
|
||||
// see the SearchMatch.nodeId note).
|
||||
const id =
|
||||
isObject(node.attrs) && typeof node.attrs.id === "string" && node.attrs.id.length > 0
|
||||
? node.attrs.id
|
||||
: topRef;
|
||||
|
||||
for (const [idx, len] of eachMatch(text, query, re, caseSensitive)) {
|
||||
total++;
|
||||
if (matches.length < limit) {
|
||||
matches.push({
|
||||
nodeId: id,
|
||||
blockIndex,
|
||||
type: node.type,
|
||||
before: text.slice(Math.max(0, idx - CONTEXT), idx),
|
||||
match: text.slice(idx, idx + len),
|
||||
after: text.slice(idx + len, idx + len + CONTEXT),
|
||||
});
|
||||
}
|
||||
}
|
||||
// A text container holds inline content only — no nested containers to
|
||||
// recurse into.
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content) descend(child, blockIndex, topRef);
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < topLevel.length; i++) {
|
||||
descend(topLevel[i], i, `#${i}`);
|
||||
}
|
||||
|
||||
return { total, truncated: total > matches.length, matches };
|
||||
}
|
||||
@@ -13,6 +13,11 @@
|
||||
// diverge on purpose (security guardrails, tuned UX, "Reversible" framing on
|
||||
// some write tools, different limits, hybrid-RRF search, etc.) stay defined
|
||||
// per-layer and are NOT represented here.
|
||||
//
|
||||
// MAINTENANCE RULE: adding, renaming, or removing a spec here (or an inline
|
||||
// registerTool in index.ts) REQUIRES updating SERVER_INSTRUCTIONS in
|
||||
// packages/mcp/src/index.ts — the intent-routing guide MCP clients receive on
|
||||
// initialize. Enforced by test/unit/server-instructions.test.mjs.
|
||||
|
||||
// Loose on purpose — see the comment above. The two zod majors expose different
|
||||
// static type surfaces, so typing this precisely would couple the registry to
|
||||
@@ -105,14 +110,68 @@ export const SHARED_TOOL_SPECS = {
|
||||
}),
|
||||
},
|
||||
|
||||
// --- in-page occurrence search (client-side, over ProseMirror plain text) ---
|
||||
|
||||
searchInPage: {
|
||||
mcpName: 'search_in_page',
|
||||
inAppKey: 'searchInPage',
|
||||
description:
|
||||
'Find every occurrence of a string (or regex) INSIDE one page and get ' +
|
||||
'WHERE each is — instead of pulling blocks one-by-one with get_node. ' +
|
||||
'Searches the plain text of each text block/cell (marks glued, so a match ' +
|
||||
'survives bold/italic/link splits; comment anchors do not interfere). ' +
|
||||
'Returns { total, truncated, matches:[{ nodeId, blockIndex, type, before, ' +
|
||||
'match, after }] }: `nodeId` is the block id (or "#<index>" for ' +
|
||||
'table/cell content) — pass it to get_node/patch_node (the "#<index>" ' +
|
||||
'form resolves with get_node but NOT patch_node, which only accepts a real ' +
|
||||
'block id). To anchor a comment, do NOT pass nodeId to create_comment (it ' +
|
||||
'has no nodeId param); build a UNIQUE text selection from before+match+' +
|
||||
'after and pass it as create_comment\'s `selection`. `blockIndex` is the ' +
|
||||
'get_outline index; `before`/`after` give ~40 chars of context to build ' +
|
||||
'that unique selection. `total` counts all ' +
|
||||
'hits and `truncated` is true when more than `limit` were found (nothing ' +
|
||||
'is silently dropped). Default is a literal, case-INSENSITIVE substring; ' +
|
||||
'set regex:true for an RE2 regular expression (linear-time, ReDoS-safe: ' +
|
||||
'char classes, word boundaries, anchors and quantifiers work; lookaround ' +
|
||||
'(?=…)/(?<=…) and backreferences \\1 are NOT supported) and ' +
|
||||
'caseSensitive:true to match case. Ideal for systematic ' +
|
||||
'editorial sweeps (unquoted "ё", straight quotes, "т.е.", stray units). An ' +
|
||||
'invalid regex or an empty query returns a clear error to fix.',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('ID of the page to search'),
|
||||
query: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('The text to find (a literal substring, or a regex when regex:true)'),
|
||||
regex: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Treat query as an RE2 regular expression — linear-time, ReDoS-safe; ' +
|
||||
'no lookaround or backreferences (default false).',
|
||||
),
|
||||
caseSensitive: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Case-sensitive matching (default false).'),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.optional()
|
||||
.describe('Max matches to RETURN (default 50, max 200); total is always reported.'),
|
||||
}),
|
||||
},
|
||||
|
||||
// --- node delete ---
|
||||
|
||||
deleteNode: {
|
||||
mcpName: 'delete_node',
|
||||
inAppKey: 'deleteNode',
|
||||
description:
|
||||
'Remove a single block by its attrs.id (from the page-JSON view) WITHOUT ' +
|
||||
'resending the whole document.',
|
||||
'Remove a single block by its attrs.id (from the page outline or ' +
|
||||
'page-JSON view) WITHOUT resending the whole document.',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1),
|
||||
nodeId: z.string().min(1),
|
||||
@@ -134,7 +193,8 @@ export const SHARED_TOOL_SPECS = {
|
||||
description:
|
||||
'Replace a single content block identified by its attrs.id with a new ' +
|
||||
'ProseMirror node, WITHOUT resending the whole document; the replacement ' +
|
||||
'keeps the same node id. Get the block id from the page-JSON view, then ' +
|
||||
'keeps the same node id. Get the block id from the page outline (cheap) ' +
|
||||
'or the page-JSON view, then ' +
|
||||
'pass a ProseMirror node to put in its place. Example node: a paragraph ' +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
||||
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
||||
@@ -169,7 +229,8 @@ export const SHARED_TOOL_SPECS = {
|
||||
'Insert a block before/after another block (by attrs.id or anchor text) ' +
|
||||
'or append it at the end (top level). For before/after you MUST provide ' +
|
||||
'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' +
|
||||
'page-JSON view. Avoids resending the whole document. Can also insert ' +
|
||||
'page outline or the page-JSON view. Avoids resending the whole document. ' +
|
||||
'Can also insert ' +
|
||||
'table structure: to add a tableRow, pass a tableRow node with position ' +
|
||||
'before/after and anchor INSIDE the target table — anchorNodeId of any ' +
|
||||
'block/cell in it, or anchorText matching the table; to add a ' +
|
||||
|
||||
@@ -45,6 +45,7 @@ const HOST_CONTRACT_METHODS = [
|
||||
"getOutline",
|
||||
"getPageJson",
|
||||
"getNode",
|
||||
"searchInPage",
|
||||
"getTable",
|
||||
"listComments",
|
||||
"getComment",
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { searchInDoc } from "../../build/lib/page-search.js";
|
||||
import { getNodeByRef } from "../../build/lib/node-ops.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document builders. Mirror the Docmost ProseMirror shape: paragraphs/headings
|
||||
// carry an attrs.id and hold text nodes; a text node may carry marks, and
|
||||
// adjacent runs with different marks are GLUED by blockPlainText so a match can
|
||||
// straddle a mark boundary. Table cells hold id-less paragraphs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const text = (t, marks) => (marks ? { type: "text", text: t, marks } : { type: "text", text: t });
|
||||
const para = (id, ...children) => ({ type: "paragraph", attrs: { id }, content: children });
|
||||
const heading = (id, level, t) => ({
|
||||
type: "heading",
|
||||
attrs: { id, level },
|
||||
content: [text(t)],
|
||||
});
|
||||
|
||||
function doc(...content) {
|
||||
return { type: "doc", content };
|
||||
}
|
||||
|
||||
test("literal substring: finds every occurrence with total/truncated and refs", () => {
|
||||
const d = doc(
|
||||
para("p1", text("The cat sat on the cat mat.")),
|
||||
heading("h1", 2, "Another cat here"),
|
||||
);
|
||||
const res = searchInDoc(d, "cat");
|
||||
assert.equal(res.total, 3);
|
||||
assert.equal(res.truncated, false);
|
||||
assert.equal(res.matches.length, 3);
|
||||
// First hit: paragraph p1, block index 0.
|
||||
assert.equal(res.matches[0].nodeId, "p1");
|
||||
assert.equal(res.matches[0].blockIndex, 0);
|
||||
assert.equal(res.matches[0].type, "paragraph");
|
||||
assert.equal(res.matches[0].match, "cat");
|
||||
// Third hit is in the heading (block index 1).
|
||||
assert.equal(res.matches[2].nodeId, "h1");
|
||||
assert.equal(res.matches[2].blockIndex, 1);
|
||||
assert.equal(res.matches[2].type, "heading");
|
||||
});
|
||||
|
||||
test("context windows: before/after are drawn from the SAME container", () => {
|
||||
const d = doc(para("p1", text("alpha beta gamma delta")));
|
||||
const res = searchInDoc(d, "gamma");
|
||||
assert.equal(res.matches.length, 1);
|
||||
assert.equal(res.matches[0].before, "alpha beta ");
|
||||
assert.equal(res.matches[0].match, "gamma");
|
||||
assert.equal(res.matches[0].after, " delta");
|
||||
});
|
||||
|
||||
test("context windows are bounded to ~40 chars each side", () => {
|
||||
const long = "x".repeat(100);
|
||||
const d = doc(para("p1", text(long + "NEEDLE" + long)));
|
||||
const res = searchInDoc(d, "NEEDLE");
|
||||
assert.equal(res.matches.length, 1);
|
||||
assert.equal(res.matches[0].before.length, 40);
|
||||
assert.equal(res.matches[0].after.length, 40);
|
||||
});
|
||||
|
||||
test("case-insensitive by default; caseSensitive:true narrows", () => {
|
||||
const d = doc(para("p1", text("Cat CAT cat")));
|
||||
assert.equal(searchInDoc(d, "cat").total, 3);
|
||||
assert.equal(searchInDoc(d, "cat", { caseSensitive: true }).total, 1);
|
||||
// Reported match preserves the ORIGINAL casing even under a folded search.
|
||||
const res = searchInDoc(d, "cat");
|
||||
assert.deepEqual(
|
||||
res.matches.map((m) => m.match),
|
||||
["Cat", "CAT", "cat"],
|
||||
);
|
||||
});
|
||||
|
||||
test("match survives an inline mark boundary (glued runs)", () => {
|
||||
// "т.е." is fractured across three text nodes by bold/italic marks.
|
||||
const d = doc(
|
||||
para(
|
||||
"p1",
|
||||
text("вводное слово, "),
|
||||
text("т", [{ type: "bold" }]),
|
||||
text(".", [{ type: "italic" }]),
|
||||
text("е", [{ type: "bold" }]),
|
||||
text(". дальше"),
|
||||
),
|
||||
);
|
||||
const res = searchInDoc(d, "т.е.");
|
||||
assert.equal(res.total, 1);
|
||||
assert.equal(res.matches[0].match, "т.е.");
|
||||
assert.equal(res.matches[0].nodeId, "p1");
|
||||
});
|
||||
|
||||
test("regex engine: character classes and word boundaries", () => {
|
||||
const d = doc(para("p1", text("v1 v22 version v3")));
|
||||
const res = searchInDoc(d, "\\bv\\d+\\b", { regex: true });
|
||||
assert.deepEqual(
|
||||
res.matches.map((m) => m.match),
|
||||
["v1", "v22", "v3"],
|
||||
);
|
||||
// "version" is not matched by \bv\d+\b.
|
||||
assert.equal(res.total, 3);
|
||||
});
|
||||
|
||||
test("regex is case-insensitive by default and respects caseSensitive", () => {
|
||||
const d = doc(para("p1", text("Foo foo FOO")));
|
||||
assert.equal(searchInDoc(d, "foo", { regex: true }).total, 3);
|
||||
assert.equal(
|
||||
searchInDoc(d, "foo", { regex: true, caseSensitive: true }).total,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("regex empty/zero-length matches are skipped, not flooded", () => {
|
||||
const d = doc(para("p1", text("abc")));
|
||||
// `a*` can match the empty string at every position; we must not emit those.
|
||||
const res = searchInDoc(d, "a*", { regex: true });
|
||||
assert.equal(res.total, 1);
|
||||
assert.equal(res.matches[0].match, "a");
|
||||
});
|
||||
|
||||
test("nodeId for a table cell paragraph WITHOUT an id falls back to #<topLevelIndex>", () => {
|
||||
// A table at top-level block index 1; its cell paragraphs carry no attrs.id.
|
||||
const cellPara = (t) => ({ type: "paragraph", content: [text(t)] });
|
||||
const d = doc(
|
||||
para("intro", text("before the table")),
|
||||
{
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{ type: "tableCell", content: [cellPara("needle in a cell")] },
|
||||
{ type: "tableHeader", content: [cellPara("another needle")] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
const res = searchInDoc(d, "needle");
|
||||
assert.equal(res.total, 2);
|
||||
// Both cell hits report the table's top-level #<index> (block 1) since the
|
||||
// cell paragraphs have no id.
|
||||
for (const m of res.matches) {
|
||||
assert.equal(m.nodeId, "#1");
|
||||
assert.equal(m.blockIndex, 1);
|
||||
}
|
||||
// Context is scoped to the specific cell, not the whole table's glued text.
|
||||
assert.equal(res.matches[0].after, " in a cell");
|
||||
assert.equal(res.matches[1].before, "another ");
|
||||
});
|
||||
|
||||
test("nodeId uses attrs.id when the container has one (paragraph & heading)", () => {
|
||||
const d = doc(heading("h9", 1, "heading needle"), para("p9", text("para needle")));
|
||||
const res = searchInDoc(d, "needle");
|
||||
assert.equal(res.matches[0].nodeId, "h9");
|
||||
assert.equal(res.matches[1].nodeId, "p9");
|
||||
});
|
||||
|
||||
test("limit caps the returned matches but total and truncated stay honest", () => {
|
||||
const d = doc(para("p1", text("x ".repeat(10).trim()))); // 10 'x'
|
||||
const res = searchInDoc(d, "x", { limit: 3 });
|
||||
assert.equal(res.total, 10);
|
||||
assert.equal(res.matches.length, 3);
|
||||
assert.equal(res.truncated, true);
|
||||
});
|
||||
|
||||
test("limit is clamped to the [1, 200] range", () => {
|
||||
const d = doc(para("p1", text("a".repeat(5))));
|
||||
// A limit above the ceiling still returns all 5 (< 200) without truncation.
|
||||
const hi = searchInDoc(d, "a", { limit: 9999 });
|
||||
assert.equal(hi.matches.length, 5);
|
||||
assert.equal(hi.truncated, false);
|
||||
// A non-positive limit clamps up to 1.
|
||||
const lo = searchInDoc(d, "a", { limit: 0 });
|
||||
assert.equal(lo.matches.length, 1);
|
||||
assert.equal(lo.total, 5);
|
||||
assert.equal(lo.truncated, true);
|
||||
});
|
||||
|
||||
test("invalid regex throws a clear tool error", () => {
|
||||
const d = doc(para("p1", text("hi")));
|
||||
assert.throws(
|
||||
() => searchInDoc(d, "(", { regex: true }),
|
||||
/invalid or unsupported regular expression/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("RE2: a catastrophic-backtracking pattern completes FAST and correctly (no ReDoS)", () => {
|
||||
// (a+)+$ against a long run of 'a' followed by a non-'a' is the classic
|
||||
// catastrophic-backtracking case that wedges the JS RegExp engine for
|
||||
// seconds/forever. Under RE2 (linear time) it returns effectively instantly.
|
||||
const d = doc(para("p1", text("a".repeat(50_000) + "b")));
|
||||
const t0 = Date.now();
|
||||
const res = searchInDoc(d, "(a+)+$", { regex: true });
|
||||
const elapsed = Date.now() - t0;
|
||||
// No '$'-anchored all-'a' run exists (there's a trailing 'b'), so no match.
|
||||
assert.equal(res.total, 0);
|
||||
assert.equal(res.matches.length, 0);
|
||||
// Generous ceiling: the JS engine would take orders of magnitude longer.
|
||||
assert.ok(elapsed < 1000, `expected fast completion, took ${elapsed}ms`);
|
||||
});
|
||||
|
||||
test("RE2: catastrophic pattern that DOES match still completes fast and finds it", () => {
|
||||
// (a+)+b matches the whole "aaa…b"; RE2 finds it in linear time.
|
||||
const d = doc(para("p1", text("a".repeat(20_000) + "b")));
|
||||
const t0 = Date.now();
|
||||
const res = searchInDoc(d, "(a+)+b", { regex: true });
|
||||
const elapsed = Date.now() - t0;
|
||||
assert.equal(res.total, 1);
|
||||
assert.equal(res.matches[0].match, "a".repeat(20_000) + "b");
|
||||
assert.ok(elapsed < 1000, `expected fast completion, took ${elapsed}ms`);
|
||||
});
|
||||
|
||||
test("RE2: unsupported lookaround/backreference patterns yield the clear unsupported-regex error", () => {
|
||||
const d = doc(para("p1", text("hello")));
|
||||
// Lookahead / lookbehind / backreference are backtracking-only features RE2
|
||||
// rejects at compile time — a clean tool error, never a hang.
|
||||
assert.throws(
|
||||
() => searchInDoc(d, "foo(?=bar)", { regex: true }),
|
||||
/invalid or unsupported regular expression/i,
|
||||
);
|
||||
assert.throws(
|
||||
() => searchInDoc(d, "(?<=foo)bar", { regex: true }),
|
||||
/invalid or unsupported regular expression/i,
|
||||
);
|
||||
assert.throws(
|
||||
() => searchInDoc(d, "(a)\\1", { regex: true }),
|
||||
/invalid or unsupported regular expression/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("F3 round-trip: every match's nodeId resolves through the REAL getNodeByRef consumer", () => {
|
||||
// A doc mixing an attrs.id paragraph and an id-less table-cell paragraph, so
|
||||
// both ref formats (block id and "#<index>") are exercised end-to-end.
|
||||
const cellPara = (t) => ({ type: "paragraph", content: [text(t)] });
|
||||
const d = doc(
|
||||
para("intro", text("find needle here")), // attrs.id ref -> "intro"
|
||||
{
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{ type: "tableCell", content: [cellPara("cell needle")] }, // id-less -> "#1"
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
const res = searchInDoc(d, "needle");
|
||||
assert.equal(res.total, 2);
|
||||
|
||||
// Match 0: an attrs.id ref must resolve to that exact paragraph.
|
||||
assert.equal(res.matches[0].nodeId, "intro");
|
||||
const byId = getNodeByRef(d, res.matches[0].nodeId);
|
||||
assert.ok(byId, "attrs.id ref must resolve via getNodeByRef");
|
||||
assert.equal(byId.type, "paragraph");
|
||||
assert.equal(byId.node.attrs.id, "intro");
|
||||
|
||||
// Match 1: an id-less table cell falls back to the table's "#<index>", which
|
||||
// getNodeByRef resolves to the TOP-LEVEL block (the table) by index.
|
||||
assert.equal(res.matches[1].nodeId, "#1");
|
||||
const byIndex = getNodeByRef(d, res.matches[1].nodeId);
|
||||
assert.ok(byIndex, "#<index> ref must resolve via getNodeByRef");
|
||||
assert.equal(byIndex.type, "table");
|
||||
});
|
||||
|
||||
test("F4: before/after are pinned correctly at string edges (clamp not dropped)", () => {
|
||||
// Match within the first CONTEXT (40) chars of a container LONGER than
|
||||
// CONTEXT: before is only the chars that exist, never a negative-index slice.
|
||||
const head = doc(para("p1", text("ab NEEDLE" + "x".repeat(100))));
|
||||
const r1 = searchInDoc(head, "NEEDLE");
|
||||
assert.equal(r1.matches.length, 1);
|
||||
assert.equal(r1.matches[0].before, "ab ");
|
||||
assert.equal(r1.matches[0].after.length, 40); // plenty of trailing 'x'
|
||||
|
||||
// Match at index 0: before is empty.
|
||||
const atStart = doc(para("p1", text("NEEDLE tail")));
|
||||
const r2 = searchInDoc(atStart, "NEEDLE");
|
||||
assert.equal(r2.matches[0].before, "");
|
||||
assert.equal(r2.matches[0].after, " tail");
|
||||
|
||||
// Match at the container END: after is empty.
|
||||
const atEnd = doc(para("p1", text("lead NEEDLE")));
|
||||
const r3 = searchInDoc(atEnd, "NEEDLE");
|
||||
assert.equal(r3.matches[0].before, "lead ");
|
||||
assert.equal(r3.matches[0].after, "");
|
||||
});
|
||||
|
||||
test("empty or whitespace-only query is rejected", () => {
|
||||
const d = doc(para("p1", text("hi")));
|
||||
assert.throws(() => searchInDoc(d, ""), /query is empty/i);
|
||||
assert.throws(() => searchInDoc(d, " "), /query is empty/i);
|
||||
assert.throws(() => searchInDoc(d, undefined), /query is empty/i);
|
||||
});
|
||||
|
||||
test("an over-long pattern is rejected (anti-ReDoS pattern cap)", () => {
|
||||
const d = doc(para("p1", text("hi")));
|
||||
assert.throws(() => searchInDoc(d, "a".repeat(1001)), /too long/i);
|
||||
});
|
||||
|
||||
test("no matches yields an empty, non-truncated result", () => {
|
||||
const d = doc(para("p1", text("nothing to see")));
|
||||
const res = searchInDoc(d, "zebra");
|
||||
assert.deepEqual(res, { total: 0, truncated: false, matches: [] });
|
||||
});
|
||||
|
||||
test("null-safe on a missing/empty doc", () => {
|
||||
assert.deepEqual(searchInDoc(null, "x"), {
|
||||
total: 0,
|
||||
truncated: false,
|
||||
matches: [],
|
||||
});
|
||||
assert.deepEqual(searchInDoc({ type: "doc" }, "x"), {
|
||||
total: 0,
|
||||
truncated: false,
|
||||
matches: [],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// Guard: every tool the MCP server registers must be routed by intent in
|
||||
// SERVER_INSTRUCTIONS — the editing guide clients receive in the initialize
|
||||
// result. Without this, new tools silently rot out of the guide and agents
|
||||
// never learn to pick them (the guide once omitted 17 of 41 tools, including
|
||||
// get_outline, which pushed agents into fetching whole documents for block
|
||||
// ids). Tool names are extracted from the SOURCE (index.ts inline
|
||||
// registrations + tool-specs.ts shared specs) so a registration added either
|
||||
// way is caught; the guide text itself is imported from the build so the test
|
||||
// checks what actually ships.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { SERVER_INSTRUCTIONS } from "../../build/index.js";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SRC = join(HERE, "..", "..", "src");
|
||||
|
||||
// Tools DELIBERATELY absent from the guide. Keep this list minimal and
|
||||
// justify every entry — the default is: every tool gets routed.
|
||||
const EXCEPTIONS = new Set([
|
||||
// Trivial and self-explanatory; carries no routing decision.
|
||||
"get_workspace",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract every registered tool name from the source. Two registration
|
||||
* mechanisms exist and both are covered:
|
||||
* - inline `server.registerTool("name", ...)` calls in index.ts;
|
||||
* - shared specs in tool-specs.ts (`mcpName: 'name'`), registered via
|
||||
* registerShared(SHARED_TOOL_SPECS.x, ...).
|
||||
*/
|
||||
function registeredToolNames() {
|
||||
const indexSrc = readFileSync(join(SRC, "index.ts"), "utf8");
|
||||
const specsSrc = readFileSync(join(SRC, "tool-specs.ts"), "utf8");
|
||||
const names = new Set();
|
||||
for (const m of indexSrc.matchAll(/registerTool\(\s*"([a-z0-9_]+)"/g)) {
|
||||
names.add(m[1]);
|
||||
}
|
||||
for (const m of specsSrc.matchAll(/mcpName:\s*['"]([a-z0-9_]+)['"]/g)) {
|
||||
names.add(m[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
test("every registered tool is mentioned in SERVER_INSTRUCTIONS", () => {
|
||||
const names = registeredToolNames();
|
||||
// Sanity: if extraction regressed (regex drift), fail loudly rather than
|
||||
// vacuously passing on an empty set.
|
||||
assert.ok(
|
||||
names.size >= 40,
|
||||
`sanity: expected to extract 40+ registered tools, got ${names.size} — ` +
|
||||
"the extraction regexes in this test likely drifted from the source",
|
||||
);
|
||||
const missing = [...names]
|
||||
.filter((n) => !EXCEPTIONS.has(n))
|
||||
// \b<name>\b: `_` is a word char, so \bget_page\b does NOT match inside
|
||||
// get_page_json — a tool can't hide behind a longer sibling's mention.
|
||||
.filter((n) => !new RegExp(`\\b${n}\\b`).test(SERVER_INSTRUCTIONS))
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`tools missing from SERVER_INSTRUCTIONS: ${missing.join(", ")} — ` +
|
||||
"update the guide in packages/mcp/src/index.ts (see its MAINTENANCE " +
|
||||
"RULE comment), or add a justified entry to EXCEPTIONS here",
|
||||
);
|
||||
});
|
||||
|
||||
test("EXCEPTIONS entries are real registered tools", () => {
|
||||
// A stale exception (tool renamed/removed) must be cleaned up, otherwise
|
||||
// the list quietly grows past its purpose.
|
||||
const names = registeredToolNames();
|
||||
for (const name of EXCEPTIONS) {
|
||||
assert.ok(
|
||||
names.has(name),
|
||||
`EXCEPTIONS entry "${name}" is not a registered tool — remove it`,
|
||||
);
|
||||
}
|
||||
});
|
||||
Generated
+124
@@ -1007,6 +1007,9 @@ importers:
|
||||
marked:
|
||||
specifier: ^17.0.1
|
||||
version: 17.0.5
|
||||
re2:
|
||||
specifier: ^1.21.0
|
||||
version: 1.25.0
|
||||
ws:
|
||||
specifier: 8.20.1
|
||||
version: 8.20.1
|
||||
@@ -2852,6 +2855,10 @@ packages:
|
||||
'@ioredis/commands@1.5.1':
|
||||
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
|
||||
|
||||
'@isaacs/fs-minipass@4.0.1':
|
||||
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@istanbuljs/load-nyc-config@1.1.0':
|
||||
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -5552,6 +5559,10 @@ packages:
|
||||
resolution: {integrity: sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==}
|
||||
hasBin: true
|
||||
|
||||
abbrev@5.0.0:
|
||||
resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
|
||||
abstract-logging@2.0.1:
|
||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||
|
||||
@@ -6019,6 +6030,10 @@ packages:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
|
||||
chownr@3.0.0:
|
||||
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chrome-trace-event@1.0.3:
|
||||
resolution: {integrity: sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -6975,6 +6990,9 @@ packages:
|
||||
resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==}
|
||||
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
|
||||
exponential-backoff@3.1.3:
|
||||
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
|
||||
|
||||
express-rate-limit@8.2.2:
|
||||
resolution: {integrity: sha512-Ybv7bqtOgA914MLwaHWVFXMpMYeR1MQu/D+z2MaLYteqBsTIp9sY3AU7mGNLMJv8eLg8uQMpE20I+L2Lv49nSg==}
|
||||
engines: {node: '>= 16'}
|
||||
@@ -7455,6 +7473,11 @@ packages:
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
install-artifact-from-github@1.6.0:
|
||||
resolution: {integrity: sha512-wKsuzN8fy8QK7iEUqyWTQmvZ1QFGPn1xyl3/1iIIDthDjS7Hn9HoPwHlNakZirWbCsbad0lZMkr6Xfbpe1pUzw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
internal-slot@1.1.0:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -7642,6 +7665,10 @@ packages:
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
isexe@4.0.0:
|
||||
resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
isomorphic.js@0.2.5:
|
||||
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
|
||||
|
||||
@@ -8477,6 +8504,10 @@ packages:
|
||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minizlib@3.1.0:
|
||||
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
@@ -8514,6 +8545,9 @@ packages:
|
||||
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
|
||||
nan@2.28.0:
|
||||
resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==}
|
||||
|
||||
nanoid@3.3.8:
|
||||
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
@@ -8606,6 +8640,11 @@ packages:
|
||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||
hasBin: true
|
||||
|
||||
node-gyp@13.0.1:
|
||||
resolution: {integrity: sha512-piOr0S10qy5THB+q5BdqkoOx65XL/tjTMUAit3vciPNp+snTOBnGunWH1Rz7XZUxf2T9uFrfT/Ty4+aC3yPeyg==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
hasBin: true
|
||||
|
||||
node-int64@0.4.0:
|
||||
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
|
||||
|
||||
@@ -8616,6 +8655,11 @@ packages:
|
||||
resolution: {integrity: sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
nopt@10.0.1:
|
||||
resolution: {integrity: sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
hasBin: true
|
||||
|
||||
normalize-path@3.0.0:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -9135,6 +9179,10 @@ packages:
|
||||
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
proc-log@7.0.0:
|
||||
resolution: {integrity: sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
@@ -9289,6 +9337,10 @@ packages:
|
||||
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
re2@1.25.0:
|
||||
resolution: {integrity: sha512-mtxKjWS+VYIt2ijgt6ohEdwzNlGPom1whyaEKJD40cBc/wqkO1vJoOyK539Qb8Xa9m4GA6hiPGDIbW/d3egSRQ==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
|
||||
react-clear-modal@2.0.18:
|
||||
resolution: {integrity: sha512-Aiv8Bw5NVm19tlUt3RLV2a1I/ya+UlyEZjREosn5G887nnusnefT+ls4AXkuP8XLn1KOah6DrM5MemV7cXgwWg==}
|
||||
peerDependencies:
|
||||
@@ -9963,6 +10015,10 @@ packages:
|
||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar@7.5.19:
|
||||
resolution: {integrity: sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
terser-webpack-plugin@5.4.0:
|
||||
resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
@@ -10618,6 +10674,11 @@ packages:
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
which@7.0.0:
|
||||
resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
hasBin: true
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -10743,6 +10804,10 @@ packages:
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yallist@5.0.0:
|
||||
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
yaml@1.10.3:
|
||||
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -13016,6 +13081,10 @@ snapshots:
|
||||
|
||||
'@ioredis/commands@1.5.1': {}
|
||||
|
||||
'@isaacs/fs-minipass@4.0.1':
|
||||
dependencies:
|
||||
minipass: 7.1.3
|
||||
|
||||
'@istanbuljs/load-nyc-config@1.1.0':
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
@@ -16075,6 +16144,8 @@ snapshots:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
abbrev@5.0.0: {}
|
||||
|
||||
abstract-logging@2.0.1: {}
|
||||
|
||||
accepts@1.3.8:
|
||||
@@ -16633,6 +16704,8 @@ snapshots:
|
||||
dependencies:
|
||||
readdirp: 4.0.2
|
||||
|
||||
chownr@3.0.0: {}
|
||||
|
||||
chrome-trace-event@1.0.3: {}
|
||||
|
||||
ci-info@4.4.0: {}
|
||||
@@ -17748,6 +17821,8 @@ snapshots:
|
||||
jest-mock: 30.3.0
|
||||
jest-util: 30.3.0
|
||||
|
||||
exponential-backoff@3.1.3: {}
|
||||
|
||||
express-rate-limit@8.2.2(express@5.2.1):
|
||||
dependencies:
|
||||
express: 5.2.1
|
||||
@@ -18283,6 +18358,8 @@ snapshots:
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
install-artifact-from-github@1.6.0: {}
|
||||
|
||||
internal-slot@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -18454,6 +18531,8 @@ snapshots:
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
isexe@4.0.0: {}
|
||||
|
||||
isomorphic.js@0.2.5: {}
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
@@ -19470,6 +19549,10 @@ snapshots:
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
minizlib@3.1.0:
|
||||
dependencies:
|
||||
minipass: 7.1.3
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mlly@1.8.0:
|
||||
@@ -19514,6 +19597,8 @@ snapshots:
|
||||
|
||||
mute-stream@2.0.0: {}
|
||||
|
||||
nan@2.28.0: {}
|
||||
|
||||
nanoid@3.3.8: {}
|
||||
|
||||
nanoid@4.0.2: {}
|
||||
@@ -19574,12 +19659,29 @@ snapshots:
|
||||
|
||||
node-gyp-build@4.8.4: {}
|
||||
|
||||
node-gyp@13.0.1:
|
||||
dependencies:
|
||||
env-paths: 2.2.1
|
||||
exponential-backoff: 3.1.3
|
||||
graceful-fs: 4.2.11
|
||||
nopt: 10.0.1
|
||||
proc-log: 7.0.0
|
||||
semver: 7.7.4
|
||||
tar: 7.5.19
|
||||
tinyglobby: 0.2.15
|
||||
undici: 7.24.0
|
||||
which: 7.0.0
|
||||
|
||||
node-int64@0.4.0: {}
|
||||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
nodemailer@8.0.5: {}
|
||||
|
||||
nopt@10.0.1:
|
||||
dependencies:
|
||||
abbrev: 5.0.0
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
notepack.io@3.0.1: {}
|
||||
@@ -20184,6 +20286,8 @@ snapshots:
|
||||
|
||||
prismjs@1.30.0: {}
|
||||
|
||||
proc-log@7.0.0: {}
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
process-warning@4.0.0: {}
|
||||
@@ -20438,6 +20542,12 @@ snapshots:
|
||||
iconv-lite: 0.7.2
|
||||
unpipe: 1.0.0
|
||||
|
||||
re2@1.25.0:
|
||||
dependencies:
|
||||
install-artifact-from-github: 1.6.0
|
||||
nan: 2.28.0
|
||||
node-gyp: 13.0.1
|
||||
|
||||
react-clear-modal@2.0.18(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
@@ -21221,6 +21331,14 @@ snapshots:
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
tar@7.5.19:
|
||||
dependencies:
|
||||
'@isaacs/fs-minipass': 4.0.1
|
||||
chownr: 3.0.0
|
||||
minipass: 7.1.3
|
||||
minizlib: 3.1.0
|
||||
yallist: 5.0.0
|
||||
|
||||
terser-webpack-plugin@5.4.0(@swc/core@1.5.25(@swc/helpers@0.5.5))(webpack@5.106.0(@swc/core@1.5.25(@swc/helpers@0.5.5))):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
@@ -21910,6 +22028,10 @@ snapshots:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
which@7.0.0:
|
||||
dependencies:
|
||||
isexe: 4.0.0
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
@@ -22007,6 +22129,8 @@ snapshots:
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yallist@5.0.0: {}
|
||||
|
||||
yaml@1.10.3: {}
|
||||
|
||||
yaml@2.8.3: {}
|
||||
|
||||
Reference in New Issue
Block a user