openwriter 0.14.0 → 0.16.0

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.
Files changed (43) hide show
  1. package/dist/client/assets/index-CbSQ8xxn.css +1 -0
  2. package/dist/client/assets/index-JMMJM_G_.js +212 -0
  3. package/dist/client/index.html +2 -2
  4. package/dist/plugins/authors-voice/dist/index.d.ts +41 -0
  5. package/dist/plugins/authors-voice/dist/index.js +206 -0
  6. package/dist/plugins/authors-voice/package.json +23 -0
  7. package/dist/plugins/image-gen/dist/index.d.ts +35 -0
  8. package/dist/plugins/image-gen/dist/index.js +141 -0
  9. package/dist/plugins/image-gen/package.json +26 -0
  10. package/dist/plugins/publish/dist/helpers.d.ts +66 -0
  11. package/dist/plugins/publish/dist/helpers.js +199 -0
  12. package/dist/plugins/publish/dist/index.d.ts +3 -0
  13. package/dist/plugins/publish/dist/index.js +1130 -0
  14. package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
  15. package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
  16. package/dist/plugins/publish/package.json +31 -0
  17. package/dist/plugins/x-api/dist/index.d.ts +27 -0
  18. package/dist/plugins/x-api/dist/index.js +240 -0
  19. package/dist/plugins/x-api/package.json +27 -0
  20. package/dist/server/comments.js +256 -0
  21. package/dist/server/documents.js +293 -20
  22. package/dist/server/enrichment.js +114 -0
  23. package/dist/server/helpers.js +63 -8
  24. package/dist/server/index.js +94 -40
  25. package/dist/server/install-skill.js +15 -0
  26. package/dist/server/logger.js +246 -0
  27. package/dist/server/markdown-parse.js +71 -14
  28. package/dist/server/markdown-serialize.js +136 -41
  29. package/dist/server/mcp.js +538 -99
  30. package/dist/server/node-blocks.js +22 -4
  31. package/dist/server/node-fingerprint.js +347 -73
  32. package/dist/server/node-matcher.js +76 -49
  33. package/dist/server/pending-overlay.js +862 -0
  34. package/dist/server/state.js +1178 -98
  35. package/dist/server/versions.js +18 -0
  36. package/dist/server/workspaces.js +42 -5
  37. package/dist/server/ws.js +194 -37
  38. package/package.json +1 -1
  39. package/skill/SKILL.md +51 -21
  40. package/skill/agents/openwriter-enrichment-minion.md +184 -0
  41. package/skill/docs/enrichment.md +179 -0
  42. package/dist/client/assets/index-BxI3DazW.js +0 -212
  43. package/dist/client/assets/index-OV13QtgQ.css +0 -1
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
20
20
  import { tiptapToBlocks } from './node-blocks.js';
21
- import { fingerprintAll } from './node-fingerprint.js';
21
+ import { fingerprintAll, slimEntry } from './node-fingerprint.js';
22
22
  // ============================================================================
23
23
  // TipTap -> Markdown
24
24
  // ============================================================================
@@ -74,25 +74,23 @@ function collectPendingState(doc) {
74
74
  return Object.keys(pending).length > 0 ? pending : undefined;
75
75
  }
76
76
  /**
77
- * Build the `nodes` frontmatter entry — one (id, fingerprint) per block
78
- * in pre-order traversal of the TipTap tree.
77
+ * Build the `nodes` frontmatter entry — one slim tuple per block in
78
+ * pre-order traversal of the TipTap tree.
79
79
  *
80
- * Each block's ID comes from its TipTap node's `attrs.id`. Fingerprints are
81
- * computed from the walker-style block list derived directly from the TipTap
82
- * tree (no separate markdown re-parsesame source of truth that builds
83
- * the visible doc).
80
+ * Disk shape is the ultra-lean tuple form from node-fingerprint.ts. Derived
81
+ * fields (position, parent indices, neighbor types, char/word counts) are
82
+ * recomputed at load time from the block tree itself they don't go to disk.
84
83
  */
85
84
  function collectNodesFrontmatter(doc) {
86
85
  const blocks = tiptapToBlocks(doc);
87
86
  const fingerprints = fingerprintAll(blocks);
88
87
  const ids = collectBlockIds(doc);
89
- // ids array is parallel to blocks array — same pre-order traversal.
90
- const entries = [];
88
+ const out = [];
91
89
  for (let i = 0; i < blocks.length; i++) {
92
90
  const id = ids[i] || generateNodeId();
93
- entries.push({ id, fp: fingerprints[i] });
91
+ out.push(slimEntry(id, fingerprints[i]));
94
92
  }
95
- return entries;
93
+ return out;
96
94
  }
97
95
  /**
98
96
  * Cap graveyard size to avoid frontmatter bloat on docs with many edits.
@@ -137,28 +135,31 @@ function collectBlockIds(doc) {
137
135
  */
138
136
  export function tiptapToMarkdown(doc, title, metadata) {
139
137
  const meta = { ...metadata, title };
140
- // Collect pending state from node attrs into frontmatter
141
- const pendingState = collectPendingState(doc);
142
- if (pendingState) {
143
- meta.pending = pendingState;
144
- }
145
- else {
146
- delete meta.pending;
147
- }
138
+ // Disk is canonical only never emit `pending:` frontmatter. Pending
139
+ // state lives in the sidecar at `_pending/{docId}.json`, separated from
140
+ // the .md file so external markdown editors see clean canonical content.
141
+ //
142
+ // If the caller passed a doc that still has in-memory pending attrs,
143
+ // serialize from a reverted clone so the body is canonical. Callers
144
+ // that have already done the split (writeToDisk's overlay path) pass
145
+ // an already-canonical doc; this revert is a no-op for them.
146
+ // adr: adr/pending-overlay-model.md
147
+ delete meta.pending;
148
+ const canonicalDoc = revertPendingForSerialization(doc);
148
149
  // Collect node identity graph (id + fingerprint per block) for next-load matcher
149
- const nodes = collectNodesFrontmatter(doc);
150
+ const nodes = collectNodesFrontmatter(canonicalDoc);
150
151
  if (nodes.length > 0) {
151
152
  meta.nodes = nodes;
152
153
  }
153
154
  else {
154
155
  delete meta.nodes;
155
156
  }
156
- // Graveyard: recently-orphaned (id, fingerprint) entries kept across saves so
157
- // paste-back/undo can restore the original ID via exact fingerprint match.
158
- // The caller (writeToDisk) puts the matcher's nextGraveyard into metadata.graveyard;
159
- // we cap it here to keep the file small.
157
+ // Graveyard: recently-orphaned entries kept across saves so paste-back/undo
158
+ // can restore the original ID via exact fingerprint match. Caller passes
159
+ // `{id, fingerprint}` objects (matcher output); we cap, slim, and emit.
160
160
  if (Array.isArray(meta.graveyard) && meta.graveyard.length > 0) {
161
- meta.graveyard = meta.graveyard.slice(0, GRAVEYARD_MAX);
161
+ const capped = meta.graveyard.slice(0, GRAVEYARD_MAX);
162
+ meta.graveyard = capped.map((g) => Array.isArray(g) ? g : slimEntry(g.id, g.fingerprint || g.fp));
162
163
  }
163
164
  else {
164
165
  delete meta.graveyard;
@@ -169,12 +170,58 @@ export function tiptapToMarkdown(doc, title, metadata) {
169
170
  delete meta[key];
170
171
  }
171
172
  const frontmatter = `---\n${JSON.stringify(meta)}\n---\n\n`;
172
- const body = nodesToMarkdown(doc.content || []);
173
+ // Serialize the body from the canonical (reverted) clone — never from the
174
+ // pending-modified live doc, otherwise the on-disk body would contain
175
+ // rewritten prose without the original anywhere to revert to.
176
+ const body = nodesToMarkdown(canonicalDoc.content || []);
173
177
  return frontmatter + body;
174
178
  }
175
- /** Convert TipTap document to markdown body only (no frontmatter). */
179
+ /** Convert TipTap document to markdown body only (no frontmatter).
180
+ * Like tiptapToMarkdown, the body is canonical (pending reverted). */
176
181
  export function tiptapToBody(doc) {
177
- return nodesToMarkdown(doc.content || []);
182
+ const canonicalDoc = revertPendingForSerialization(doc);
183
+ return nodesToMarkdown(canonicalDoc.content || []);
184
+ }
185
+ /**
186
+ * Deep clone of `doc` with pending decorations reverted, used by the
187
+ * markdown serializer to ensure disk content is canonical. Mirrors
188
+ * state.cloneWithPendingReverted but is local to the serializer to
189
+ * avoid a state.ts → markdown-serialize.ts cycle.
190
+ *
191
+ * - status='insert' → drop the node
192
+ * - status='rewrite' → restore from pendingOriginalContent (or drop if absent)
193
+ * - status='delete' → keep but clear pending attrs
194
+ * - no status → keep, strip stray pending attrs
195
+ */
196
+ const PENDING_KEYS = ['pendingStatus', 'pendingOriginalContent', 'pendingGroupId', 'pendingTextEdits', 'pendingSelectionFrom', 'pendingSelectionTo', 'pendingOriginalFrom', 'pendingOriginalTo', 'pendingOrphan', 'pendingStaleBaseline'];
197
+ function revertPendingForSerialization(doc) {
198
+ function clean(node) {
199
+ const clone = JSON.parse(JSON.stringify(node));
200
+ if (clone.attrs) {
201
+ for (const k of PENDING_KEYS)
202
+ delete clone.attrs[k];
203
+ }
204
+ if (clone.content)
205
+ clone.content = walk(clone.content);
206
+ return clone;
207
+ }
208
+ function walk(nodes) {
209
+ const result = [];
210
+ for (const node of nodes || []) {
211
+ const status = node?.attrs?.pendingStatus;
212
+ if (status === 'insert')
213
+ continue;
214
+ if (status === 'rewrite') {
215
+ const original = node.attrs?.pendingOriginalContent;
216
+ if (original)
217
+ result.push(clean(original));
218
+ continue;
219
+ }
220
+ result.push(clean(node));
221
+ }
222
+ return result;
223
+ }
224
+ return { type: 'doc', content: walk(doc?.content || []) };
178
225
  }
179
226
  function nodesToMarkdown(nodes) {
180
227
  let result = '';
@@ -275,28 +322,76 @@ function taskListToMarkdown(items, indent) {
275
322
  }
276
323
  return result + '\n';
277
324
  }
325
+ /**
326
+ * Serialize a TipTap table node to GFM markdown.
327
+ *
328
+ * Critical invariants (each one's absence causes silent table → paragraph
329
+ * loss on round-trip — observed live as `sync-check FAIL: expected table,
330
+ * got paragraph` on the Beat Sheet doc):
331
+ *
332
+ * 1. ALWAYS emit the header-separator row `| --- | --- |` after the first
333
+ * row, regardless of whether any cell is a `tableHeader`. GFM table
334
+ * recognition requires the delimiter row — without it, markdown-it
335
+ * parses each `| ... |` line as a paragraph and the entire table is
336
+ * dropped. (One-time consequence: a header-less table's first row
337
+ * becomes `tableHeader` cells after the first round-trip. Stable
338
+ * thereafter.)
339
+ *
340
+ * 2. Escape `|` inside cell text as `\|` so it doesn't terminate the cell
341
+ * column.
342
+ *
343
+ * 3. Collapse multi-paragraph cells with `<br>` joiners. The inline
344
+ * cell format can't represent multiple block paragraphs; without
345
+ * collapsing, only the first paragraph round-trips and the rest are
346
+ * silently lost.
347
+ *
348
+ * 4. Ensure a blank line precedes the table block (caller does `\n\n`
349
+ * tailing on prior nodes; we keep the leading newline minimal).
350
+ */
278
351
  function tableToMarkdown(node) {
279
352
  const rows = node.content || [];
280
353
  if (rows.length === 0)
281
354
  return '';
355
+ function cellContentToText(cell) {
356
+ const content = cell.content || [];
357
+ if (content.length === 0)
358
+ return '';
359
+ // Each cell typically holds one paragraph, but a TipTap table can carry
360
+ // multi-paragraph cells (and arbitrary blocks). Concatenate paragraphs
361
+ // with <br> so no inline content is dropped.
362
+ const parts = [];
363
+ for (const child of content) {
364
+ if (child.type === 'paragraph') {
365
+ parts.push(inlineToMarkdown(child.content));
366
+ }
367
+ else if (child.content) {
368
+ // Non-paragraph block (rare in tables) — fall through to inline.
369
+ parts.push(inlineToMarkdown(child.content));
370
+ }
371
+ }
372
+ // Escape pipes and replace newlines with <br>.
373
+ return parts.join('<br>').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
374
+ }
282
375
  const lines = [];
283
- let isFirstRow = true;
284
- for (const row of rows) {
376
+ const firstRowCells = rows[0]?.content || [];
377
+ const columnCount = firstRowCells.length;
378
+ for (let r = 0; r < rows.length; r++) {
379
+ const row = rows[r];
285
380
  const cells = row.content || [];
286
- const cellTexts = cells.map((cell) => {
287
- const para = cell.content?.[0];
288
- return para ? inlineToMarkdown(para.content) : '';
289
- });
381
+ const cellTexts = cells.map(cellContentToText);
382
+ // Pad short rows so the markdown table has consistent column count.
383
+ while (cellTexts.length < columnCount)
384
+ cellTexts.push('');
290
385
  lines.push(`| ${cellTexts.join(' | ')} |`);
291
- if (isFirstRow) {
292
- const hasHeaders = cells.some((c) => c.type === 'tableHeader');
293
- if (hasHeaders) {
294
- lines.push(`| ${cellTexts.map(() => '---').join(' | ')} |`);
295
- }
296
- isFirstRow = false;
386
+ if (r === 0) {
387
+ // ALWAYS emit the separator GFM parsing requires it for table
388
+ // recognition. This is the load-bearing invariant.
389
+ lines.push(`| ${Array(columnCount).fill('---').join(' | ')} |`);
297
390
  }
298
391
  }
299
- return lines.join('\n') + '\n\n';
392
+ // Leading blank line ensures we're not glued to the prior block (which
393
+ // would cause the table to be consumed as a paragraph continuation).
394
+ return '\n' + lines.join('\n') + '\n\n';
300
395
  }
301
396
  // ---- Inline mark serialization ----
302
397
  const SERIALIZED_MARKS = ['bold', 'italic', 'code', 'strike', 'underline', 'highlight', 'subscript', 'superscript', 'link'];