mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - People write task requests and chat messages in the Markdown editor, and the stored text becomes the agent's prompt > - MDXEditor supports `==highlight==` marks, and its exporter escapes every `=` in prose to protect that syntax > - The editor therefore stores `a=b` as `a\=b` and `x=>y` as `x\=>y` > - A command or code snippet in a chat message then reaches the agent as invalid code > - This pull request removes the `=` escape when the escape cannot change how the text parses, as #19350 did for intraword underscores > - The benefit is that commands, assignments and arrows reach agents exactly as the person typed them ## Linked Issues or Issue Description Refs #19350 **What happened?** A chat evaluation sent a `node -e` command through the chat composer. The stored comment contained `const fs\=require(...)` and `()\=>`. Every `=` had a backslash. The agent received invalid JavaScript, and an exact-text check on the stored message failed. **Expected behavior** Text the person types is stored as typed. A `=` that cannot start or end a highlight needs no escape. **Steps to reproduce** 1. Type `a=b` or `x => y` in any Markdown editor field, such as the chat composer. 2. Read the stored value through the API. 3. Before this change it is `a\=b` or `x \=> y`. ## What Changed - New `ui/src/lib/equals-markdown.ts`: `unescapeEqualsSigns` removes `\=` escapes from prose only when they cannot change the parse: - any run of escaped `=` whose length is not exactly two (a highlight sequence is exactly two `=`); - a run of exactly two with whitespace or the text edge on both sides (such a run can neither open nor close a highlight). - Other two-runs, such as `a\=\=b` or `\=\=text\=\=`, stay escaped. Escaped backslashes (`\\=`) stay as they are. - `ui/src/lib/underscore-markdown.ts`: the code-range walk is now the shared `rewriteMarkdownProse` helper. Code spans, code blocks and raw HTML stay byte-identical for both rewrites. Underscore behavior does not change. - `MarkdownEditor.tsx`: `toStoredMarkdown` applies the new step after the underscore step, on the exporter's exact text. - Tests: unit cases for commands, `===`, spaced `==`, quotes and lists, highlight-capable runs, code spans and blocks, raw HTML and escaped backslashes. One editor test for a pasted command with a code block. ## Verification - `npx vitest run src/components/MarkdownEditor.test.tsx src/lib src/components/task-chat` in `ui/`: 280 files, 3388 tests passed. - Negative control: with the new step removed from `toStoredMarkdown`, the new editor test fails. - Parser check: I parsed the unescaped forms with `micromark-extension-highlight-mark` 1.2.0, the version MDXEditor uses. `a=b and c=d`, `x === y`, `a == b == c`, `()=>x` and `x!==y` produce zero highlight nodes. The controls `==a==`, `a ==b== c` and `say ==this== now` produce one highlight node each. - `tsc -p ui/tsconfig.json`: no errors in the changed files. The remaining errors are in files this change does not touch. ## Risks - Low risk. The step only removes an escape when the highlight tokenizer cannot use that `=`. Text the parser cannot place is returned unchanged. - Some `==` in code-like prose (for example `x!==y`) stays escaped. This is intentional: such a run can open a highlight. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
79 lines
3.3 KiB
TypeScript
79 lines
3.3 KiB
TypeScript
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
|
|
/**
|
|
* MDXEditor (via `mdast-util-to-markdown`) exports every literal `_` as `\_`,
|
|
* including underscores inside a word such as `snake_case` or `E2E_ASK_12`.
|
|
* CommonMark never treats an intraword underscore as emphasis, so that escape
|
|
* is pure transport noise. Stored task text feeds agent prompts, and agents then
|
|
* see (and repeat) backslashes the person never typed.
|
|
*
|
|
* This helper removes the escape only from a run of escaped underscores that
|
|
* sits between two letters or digits (`snake\_case`, `foo\_\_bar`), which cannot
|
|
* change how the markdown renders. It parses the markdown so that code spans and
|
|
* fenced or indented code blocks at any container depth, and raw HTML, stay
|
|
* byte-identical. Run it on the editor's exact export, before any other
|
|
* transport-escape removal changes how that text parses.
|
|
* Word-boundary escapes (`\_emphasis\_`, `\_\_init\_\_`, which would otherwise
|
|
* render as emphasis) and escaped backslashes (`\\_`) are left unchanged.
|
|
*/
|
|
|
|
const INTRAWORD_ESCAPED_RUN_RE = /(?<=[\p{L}\p{N}])((?:\\_)+)(?=[\p{L}\p{N}])/gu;
|
|
|
|
interface PositionedNode {
|
|
type: string;
|
|
position?: { start: { offset?: number }; end: { offset?: number } };
|
|
children?: PositionedNode[];
|
|
}
|
|
|
|
/** True when the character at `index` is escaped by an odd number of backslashes. */
|
|
export function isEscaped(text: string, index: number): boolean {
|
|
let count = 0;
|
|
for (let i = index - 1; i >= 0 && text[i] === "\\"; i -= 1) count += 1;
|
|
return count % 2 === 1;
|
|
}
|
|
|
|
function unescapeProse(text: string): string {
|
|
return text.replace(INTRAWORD_ESCAPED_RUN_RE, (run: string, _group: string, offset: number, whole: string) =>
|
|
// `a\\_b` is an escaped backslash followed by a literal underscore: keep it.
|
|
isEscaped(whole, offset) ? run : "_".repeat(run.length / 2));
|
|
}
|
|
|
|
/** Source ranges of code spans and code blocks, in document order. */
|
|
function codeRanges(node: PositionedNode, ranges: Array<[number, number]>): Array<[number, number]> {
|
|
// Raw HTML does not process backslash escapes, so a `\_` there is literal too.
|
|
if (node.type === "code" || node.type === "inlineCode" || node.type === "html") {
|
|
const start = node.position?.start.offset, end = node.position?.end.offset;
|
|
if (typeof start === "number" && typeof end === "number") ranges.push([start, end]);
|
|
return ranges;
|
|
}
|
|
for (const child of node.children ?? []) codeRanges(child, ranges);
|
|
return ranges;
|
|
}
|
|
|
|
/**
|
|
* Apply `rewrite` to prose only: code spans, code blocks and raw HTML stay
|
|
* byte-identical. Text the parser cannot place is returned unchanged.
|
|
*/
|
|
export function rewriteMarkdownProse(markdown: string, rewrite: (prose: string) => string): string {
|
|
let ranges: Array<[number, number]>;
|
|
try {
|
|
ranges = codeRanges(fromMarkdown(markdown) as PositionedNode, []);
|
|
} catch {
|
|
// Never rewrite text the parser could not place.
|
|
return markdown;
|
|
}
|
|
let result = "";
|
|
let cursor = 0;
|
|
for (const [start, end] of ranges) {
|
|
if (start < cursor) continue;
|
|
result += rewrite(markdown.slice(cursor, start)) + markdown.slice(start, end);
|
|
cursor = end;
|
|
}
|
|
return result + rewrite(markdown.slice(cursor));
|
|
}
|
|
|
|
export function unescapeIntrawordUnderscores(markdown: string): string {
|
|
if (!markdown.includes("\\_")) return markdown;
|
|
return rewriteMarkdownProse(markdown, unescapeProse);
|
|
}
|