openwriter 0.28.0 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -830,3 +830,45 @@ function splitParagraphOnDoubleBreaks(content, firstId) {
830
830
  content: g,
831
831
  }));
832
832
  }
833
+ /**
834
+ * Heal fused double-`<br>` paragraphs across a node array — the structured/JSON
835
+ * sibling of the per-paragraph `splitParagraphOnDoubleBreaks` heal that runs on
836
+ * the markdown-string parse path. Agent writes and Author's Voice rewrites can
837
+ * arrive as TipTap JSON, which never passes through markdown-it, so a tweet-style
838
+ * paragraph carrying a run of 2+ `hardBreak`s would otherwise enter canonical as
839
+ * a single fused node the parser refuses to reproduce — breaking the serialize→
840
+ * reparse round-trip (sync-check FAIL), destabilizing the node-identity matcher,
841
+ * and corrupting pending decorations.
842
+ *
843
+ * Canonical form for every doc type is separate paragraph nodes; intra-paragraph
844
+ * SINGLE `<br>`s (legitimate soft line breaks) pass through untouched. Idempotent:
845
+ * already-split content (e.g. from the markdown path) is returned unchanged, with
846
+ * the original node and all its attrs preserved when no fusion is present.
847
+ *
848
+ * adr: adr/tweet-paragraph-convention.md
849
+ */
850
+ export function splitFusedParagraphs(nodes) {
851
+ if (!Array.isArray(nodes))
852
+ return nodes;
853
+ const out = [];
854
+ for (const node of nodes) {
855
+ if (node?.type === 'paragraph' && Array.isArray(node.content)) {
856
+ const split = splitParagraphOnDoubleBreaks(node.content, node.attrs?.id || null);
857
+ if (split.length <= 1) {
858
+ // No fusion — keep the original node intact (preserves all attrs).
859
+ out.push(node);
860
+ }
861
+ else {
862
+ // Fusion healed: first chunk inherits the original node's attrs/id,
863
+ // the rest become fresh paragraph nodes.
864
+ split.forEach((p, idx) => {
865
+ out.push(idx === 0 ? { ...node, content: p.content } : p);
866
+ });
867
+ }
868
+ }
869
+ else {
870
+ out.push(node);
871
+ }
872
+ }
873
+ return out;
874
+ }
@@ -11,7 +11,7 @@ import { tiptapToMarkdown } from './markdown-serialize.js';
11
11
  import { markdownToTiptap } from './markdown-parse.js';
12
12
  import { shapeOfTiptap, compareShapes, formatSyncReport, } from './node-sync-check.js';
13
13
  export { tiptapToMarkdown, tiptapToBody, nodeText, inlineToMarkdown } from './markdown-serialize.js';
14
- export { markdownToTiptap, markdownToNodes } from './markdown-parse.js';
14
+ export { markdownToTiptap, markdownToNodes, splitFusedParagraphs } from './markdown-parse.js';
15
15
  export { shapeOfTiptap, computeShape, compareShapes, formatSyncReport, } from './node-sync-check.js';
16
16
  /**
17
17
  * Serialize TipTap to markdown, then re-parse and verify that the round-trip
@@ -25,7 +25,7 @@ import { toCompactFormat, compactNodes, parseMarkdownContent } from './compact.j
25
25
  import matter from 'gray-matter';
26
26
  import { getUpdateInfo } from './update-check.js';
27
27
  import { listVersions, forceSnapshot, getVersionContent } from './versions.js';
28
- import { tiptapToMarkdown } from './markdown.js';
28
+ import { tiptapToMarkdown, splitFusedParagraphs } from './markdown.js';
29
29
  import { loadDocFromDisk } from './pending-overlay.js';
30
30
  import { getComments, getCommentCount, getGlobalCommentSummary, resolveComments } from './comments.js';
31
31
  import { readTasks, addTask, updateTask, removeTask } from './tasks.js';
@@ -293,14 +293,19 @@ export const TOOL_REGISTRY = [
293
293
  if (typeof resolved.content === 'string') {
294
294
  resolved.content = parseMarkdownContent(resolved.content);
295
295
  }
296
- // Tweet docs used to collapse multi-paragraph content into a single
297
- // paragraph with hardBreaks (mergeParagraphsToHardBreaks). That made
298
- // every multi-paragraph write land as ONE pending-insert decoration
299
- // for review, which destroys per-paragraph accept/reject. Each
300
- // paragraph the agent writes is a distinct review unit; preserve
301
- // that structure. Thread separation (multiple tweets in a thread)
302
- // is signaled explicitly by horizontalRule nodes from the agent,
303
- // not implied by paragraph count.
296
+ // Canonical form for every doc type (including X templates) is separate
297
+ // paragraph nodes one node per review unit, intra-paragraph single
298
+ // <br>s preserved. Heal any fused double-<br> paragraph that arrives as
299
+ // TipTap JSON: the markdown-string path above already splits via
300
+ // markdown-it, but JSON content bypasses it, so an X-style fused node
301
+ // would otherwise enter canonical and break the serialize→reparse
302
+ // round-trip (sync-check FAIL), the node-identity matcher, and the
303
+ // pending decorations. Idempotent on already-split content.
304
+ // adr: adr/tweet-paragraph-convention.md
305
+ if (resolved.content != null && resolved.operation !== 'delete') {
306
+ const asArray = Array.isArray(resolved.content) ? resolved.content : [resolved.content];
307
+ resolved.content = splitFusedParagraphs(asArray);
308
+ }
304
309
  return resolved;
305
310
  });
306
311
  // Auto-clean: if doc has only a single empty paragraph and first change is
@@ -621,12 +626,8 @@ export const TOOL_REGISTRY = [
621
626
  try {
622
627
  let doc;
623
628
  if (typeof content === 'string') {
624
- // Don't collapse multi-paragraph content to a single hardBreak-
625
- // separated paragraph for tweet docs. Each paragraph is a
626
- // distinct review unit (its own pending-insert decoration); the
627
- // user accepts/rejects per paragraph. Explicit thread structure
628
- // (multiple tweets in a thread) comes from horizontalRule nodes
629
- // in the agent's input, not from paragraph count.
629
+ // Canonical form is separate paragraph nodes for every doc type
630
+ // (including X templates) each paragraph a distinct review unit.
630
631
  doc = { type: 'doc', content: parseMarkdownContent(content) };
631
632
  }
632
633
  else if (content?.type === 'doc' && Array.isArray(content.content)) {
@@ -638,6 +639,10 @@ export const TOOL_REGISTRY = [
638
639
  content: [{ type: 'text', text: 'Error: content must be a markdown string or TipTap JSON { type: "doc", content: [...] }' }],
639
640
  };
640
641
  }
642
+ // Heal fused double-<br> paragraphs that arrive as TipTap JSON (X-template
643
+ // content bypasses the markdown-string split). Idempotent on the string
644
+ // path above. adr: adr/tweet-paragraph-convention.md
645
+ doc.content = splitFusedParagraphs(doc.content);
641
646
  // Non-active target: write directly to disk without disrupting the user's view
642
647
  const targetIsNonActive = filename && filename !== getActiveFilename();
643
648
  if (targetIsNonActive) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.28.0",
3
+ "version": "0.28.1",
4
4
  "description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
5
5
  "type": "module",
6
6
  "license": "MIT",