Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f59996c
fix(remend): stop appending emphasis closers that match no opener
bendrucker Oct 7, 2026
197b451
fix(remend): heal every open underscore run in one pass
bendrucker Oct 7, 2026
ebd0094
fix(remend): heal emphasis with one CommonMark delimiter engine
bendrucker Oct 8, 2026
95f3940
fix(remend): heal emphasis after prices and keep A*算法 literal
bendrucker Oct 8, 2026
183d05c
fix(remend): heal bold after a closer that failed flanking
bendrucker Oct 8, 2026
11b92d3
fix(remend): keep inline code and link heals inside their block
bendrucker Oct 8, 2026
e4fef1b
fix(remend): end emphasis at a streaming dash and heal [`[ once
bendrucker Oct 8, 2026
627ef97
fix(remend): leave fill-in blanks of four or more underscores open
bendrucker Oct 8, 2026
c647590
fix(remend): match link brackets outside code spans
bendrucker Oct 12, 2026
722015a
fix(remend): stop healing openers whose line has ended
bendrucker Oct 12, 2026
7c9dda7
test(remend): count visible heal characters as glitches
bendrucker Oct 12, 2026
70b2c96
fix(remend): leave a * before a comma or semicolon literal
bendrucker Oct 12, 2026
200fb72
fix(remend): let a lone list marker end only blocks it can interrupt
bendrucker Oct 15, 2026
46bbbf4
Merge upstream main into #670
nishilfaldu Oct 19, 2026
0f9e19d
fix(remend): complete links only in the current block
nishilfaldu Oct 19, 2026
43c6616
perf(streamdown): repair only the end of a streaming message
nishilfaldu Oct 19, 2026
a785125
fix(streamdown): repair the whole message where remend reads all of it
nishilfaldu Oct 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/remend-escaped-markers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"remend": minor
---

Heal emphasis the way CommonMark and GFM parse it

- Closes only openers that exist, innermost first, within the paragraph, list item or link text that holds them. An open code span closes before the emphasis around it.
- Leaves literal markers alone: `**Note:**This`, `*.ts`, `x**2`, `(+, -, *, /)`, fill-in blanks like `______`, and runs after an escaped marker like `\**`. A trailing backslash is escaped so it can't escape the closers.
- Stops closing an opener once its line has ended and the next line has started, since models close emphasis on the line that opens it. A stray `*` in code written outside a fence, like `char *name`, no longer italicizes what follows.
- Closes an inline code span only within its own block, and never with a closer that would start a fence. A `[` before a complete link stays literal instead of healing a second link.
- The `bold`, `italic`, `boldItalic` and `strikethrough` options still apply, but the built-in handlers they controlled are now one `emphasis` handler at priority 30. Custom handlers ordered against the old priorities may need new ones.
5 changes: 5 additions & 0 deletions .changeset/remend-link-block-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"remend": patch
---

Complete unfinished links and images only in the current block: the paragraph, list item, heading or table row at the end of the text. An unclosed `[` or `](` in an earlier block no longer adds `](streamdown:incomplete-link)` to later text, or drops the text after it.
5 changes: 5 additions & 0 deletions .changeset/streamdown-tail-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"streamdown": patch
---

While streaming, repair only the end of the message instead of the whole message on every update. The footnote and link-definition checks now scan only the new text. Rendered output is unchanged.
13 changes: 5 additions & 8 deletions apps/website/content/docs/termination.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -278,27 +278,24 @@ Built-in handlers use priorities 0-75:
| `htmlTags` | 10 |
| `setextHeadings` | 15 |
| `links` | 20 |
| `boldItalic` | 30 |
| `bold` | 35 |
| `italic` | 40-42 |
| `emphasis` (bold, italic, bold italic, strikethrough) | 30 |
| `inlineCode` | 50 |
| `strikethrough` | 60 |
| `katex` | 70 |

Custom handlers default to priority 100, running after all built-ins. Set a lower priority to run before specific built-ins.

**Example: Running Before Bold Handling**
**Example: Running Before Emphasis Handling**

To process content before bold formatting is completed (priority 30), set a lower priority:
To process content before emphasis is completed (priority 30), set a lower priority:

```typescript
const preprocessHandler: RemendHandler = {
name: 'preprocess',
handle: (text) => {
// Transform content before bold is completed
// Transform content before emphasis is completed
return text.replace(/\{\{highlight\}\}/g, '**');
},
priority: 25, // Runs before bold (35) and after links (20)
priority: 25, // Runs before emphasis (30) and after links (20)
};
```

Expand Down
5 changes: 1 addition & 4 deletions packages/remend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,8 @@ Built-in handlers use priorities 0-75. Custom handlers default to 100 (run after
| `htmlTags` | 10 |
| `setextHeadings` | 15 |
| `links` | 20 |
| `boldItalic` | 30 |
| `bold` | 35 |
| `italic` | 40-42 |
| `emphasis` (bold, italic, bold italic, strikethrough) | 30 |
| `inlineCode` | 50 |
| `strikethrough` | 60 |
| `katex` | 70 |
| `inlineKatex` | 75 |
| Custom (default) | 100 |
Expand Down
219 changes: 219 additions & 0 deletions packages/remend/__benchmarks__/stream-glitches.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { fromMarkdown } from "mdast-util-from-markdown";
import { gfmStrikethroughFromMarkdown } from "mdast-util-gfm-strikethrough";
import { gfmStrikethrough } from "micromark-extension-gfm-strikethrough";
import { it } from "vitest";
import remend from "../src";

// Streams each response in a JSONL corpus through remend and counts frames
// whose render disagrees with the finished response's render. Run with
// CORPUS=<file.jsonl> [BASELINE=<path to another remend src>] [OUT=<file.json>]

interface Node {
children?: Node[];
position?: { end: { offset?: number }; start: { offset?: number } };
type: string;
}

interface Sample {
model: string;
source: string;
text: string;
}

type Heal = (text: string) => string;

const toSample = (line: string): Sample => {
const value: unknown = JSON.parse(line);
if (
typeof value !== "object" ||
value === null ||
!("text" in value) ||
typeof value.text !== "string"
) {
throw new Error(`Corpus line has no text: ${line.slice(0, 80)}`);
}
const field = (key: string): string =>
key in value ? String(value[key as keyof typeof value]) : "unknown";
return { model: field("model"), source: field("source"), text: value.text };
};

const STEP = 4;
const STYLED = new Set(["emphasis", "strong", "delete", "inlineCode"]);
const ASCII_PUNCTUATION = /[!-/:-@[-`{-~]/;
const LEADING_TICKS = /^`+/;
const LETTER = /\p{L}/gu;
const DIGIT = /\d/g;
const LETTERS = /a+/g;
const ZERO_WIDTH_SPACE = "\u200B";

const parse = (markdown: string): Node =>
fromMarkdown(markdown, {
extensions: [gfmStrikethrough()],
mdastExtensions: [gfmStrikethroughFromMarkdown()],
});

// Each source offset's rendering: "" when it is syntax, otherwise the inline
// styles around the character it shows
const renderStyles = (markdown: string, root: Node, shift = 0): string[] => {
const styles: string[] = new Array(markdown.length + shift).fill("");
const visit = (node: Node, stack: string) => {
const start = node.position?.start.offset ?? 0;
const end = node.position?.end.offset ?? 0;
const style = STYLED.has(node.type) ? `${stack}/${node.type}` : stack;
if (node.type === "text") {
for (let i = start; i < end; i++) {
if (
markdown[i] === "\\" &&
i + 1 < end &&
ASCII_PUNCTUATION.test(markdown[i + 1])
) {
i++;
}
styles[shift + i] = `v${style}`;
}
} else if (node.type === "inlineCode") {
const raw = markdown.slice(start, end);
const ticks = raw.length - raw.replace(LEADING_TICKS, "").length;
for (let i = start + ticks; i < end - ticks; i++) {
styles[shift + i] = `v${style}`;
}
}
for (const child of node.children ?? []) {
visit(child, style);
}
};
visit(root, "");
return styles;
};

// Top-level block starts, since emphasis never crosses them
const blockStarts = (root: Node): number[] =>
(root.children ?? []).map((child) => child.position?.start.offset ?? 0);

// Characters of the prefix whose rendering differs from the final render,
// plus characters the heal added that render as visible text
const mismatches = (
prefix: string,
healed: string,
from: number,
final: string[]
): number => {
let cut = 0;
while (cut < prefix.length && prefix[cut] === healed[cut]) {
cut++;
}
const added = healed.length - prefix.length;
const frame = renderStyles(
healed.slice(from),
parse(healed.slice(from)),
from
);
let count = 0;
for (let at = cut; at < cut + added; at++) {
if ((frame[at] ?? "") !== "" && healed[at] !== ZERO_WIDTH_SPACE) {
count++;
}
}
for (let i = from; i < prefix.length; i++) {
const at = i < cut ? i : i + added;
if ((frame[at] ?? "") !== final[i]) {
count++;
}
}
return count;
};

// A coarse shape of the text before a cut, for grouping examples
const shapeOf = (tail: string): string =>
tail.replace(LETTER, "a").replace(DIGIT, "0").replace(LETTERS, "a");

interface Arm {
frames: number;
glitches: number;
}

const measure = (samples: Sample[], arms: Record<string, Heal>) => {
const byGroup = new Map<string, Record<string, Arm>>();
const divergent = { baselineCloser: 0, branchCloser: 0, tie: 0 };
const worse = new Map<string, { count: number; example: string }>();
for (const sample of samples) {
const group = `${sample.source} ${sample.model}`;
const totals =
byGroup.get(group) ??
Object.fromEntries(
Object.keys(arms).map((name) => [name, { frames: 0, glitches: 0 }])
);
byGroup.set(group, totals);
const finalTree = parse(sample.text);
const final = renderStyles(sample.text, finalTree);
const starts = blockStarts(finalTree);
for (let end = STEP; end < sample.text.length; end += STEP) {
const prefix = sample.text.slice(0, end);
const from = starts.findLast((start) => start < end) ?? 0;
const healed = Object.fromEntries(
Object.entries(arms).map(([name, heal]) => [name, heal(prefix)])
);
const counts: Record<string, number> = {};
for (const name of Object.keys(arms)) {
counts[name] = mismatches(prefix, healed[name], from, final);
totals[name].frames++;
if (counts[name] > 0) {
totals[name].glitches++;
}
}
if (arms.baseline && healed.baseline !== healed.branch) {
if (counts.branch < counts.baseline) {
divergent.branchCloser++;
} else if (counts.branch > counts.baseline) {
divergent.baselineCloser++;
const tail = prefix.slice(Math.max(from, end - 24));
const shape = shapeOf(tail);
const entry = worse.get(shape) ?? {
count: 0,
example: JSON.stringify({
tail,
baseline: healed.baseline.slice(Math.max(from, end - 24)),
branch: healed.branch.slice(Math.max(from, end - 24)),
final: sample.text.slice(Math.max(from, end - 24), end + 24),
}),
};
entry.count++;
worse.set(shape, entry);
} else {
divergent.tie++;
}
}
}
}
return {
divergent,
groups: Object.fromEntries(byGroup),
worse: [...worse.entries()]
.sort((a, b) => b[1].count - a[1].count)
.slice(0, 40)
.map(([shape, entry]) => ({ shape, ...entry })),
};
};

it.skipIf(!process.env.CORPUS)(
"measures streaming glitches against the final render",
async () => {
const samples: Sample[] = readFileSync(process.env.CORPUS ?? "", "utf8")
.split("\n")
.filter(Boolean)
.map(toSample);
const arms: Record<string, Heal> = { branch: remend };
if (process.env.BASELINE) {
const baseline = await import(resolve(process.env.BASELINE));
arms.baseline = baseline.default;
}
const report = measure(samples, arms);
writeFileSync(
process.env.OUT ?? "stream-glitches.json",
JSON.stringify(report, null, 2)
);
},
24 * 60 * 60 * 1000
);
2 changes: 1 addition & 1 deletion packages/remend/__tests__/bold-italic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe("bold-italic formatting (***)", () => {

it("should handle nested formatting with triple asterisks", () => {
expect(remend("***bold-italic with `code")).toBe(
"***bold-italic with `code***`"
"***bold-italic with `code`***"
);
});

Expand Down
Loading
Loading