openwriter 0.11.0 → 0.12.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.
@@ -10,8 +10,8 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-DCMxNd__.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-Cc-WcvZz.css">
13
+ <script type="module" crossorigin src="/assets/index-CNmzNvB_.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-CRImKlcp.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -122,6 +122,7 @@ export function listDocuments() {
122
122
  ...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
123
123
  ...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
124
124
  ...(data.variantType ? { variantType: data.variantType } : {}),
125
+ ...(data.autoAccept === true ? { autoAccept: true } : {}),
125
126
  };
126
127
  }
127
128
  catch {
@@ -162,6 +163,7 @@ export function listDocuments() {
162
163
  ...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
163
164
  ...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
164
165
  ...(data.variantType ? { variantType: data.variantType } : {}),
166
+ ...(data.autoAccept === true ? { autoAccept: true } : {}),
165
167
  });
166
168
  }
167
169
  catch { /* skip unreadable external files */ }
@@ -11,7 +11,7 @@ import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadc
11
11
  import { TOOL_REGISTRY } from './mcp.js';
12
12
  import { z } from 'zod';
13
13
  import { zodToJsonSchema } from 'zod-to-json-schema';
14
- import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, removePendingCacheEntry, clearAllCaches } from './state.js';
14
+ import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, removePendingCacheEntry, clearAllCaches, stripPendingAttrs, stripPendingAttrsFromFile, setAutoAcceptOnFile } from './state.js';
15
15
  import { syncPostHistory } from './post-sync.js';
16
16
  import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename, batchResolve } from './documents.js';
17
17
  import { createWorkspaceRouter } from './workspace-routes.js';
@@ -154,6 +154,43 @@ export async function startHttpServer(options = {}) {
154
154
  res.status(500).json({ error: err.message });
155
155
  }
156
156
  });
157
+ // Toggle auto-accept on a document. Body: { filename, enabled }.
158
+ // When enabling, any currently-pending changes are accepted in place so the
159
+ // user enters a clean state — agent writes from this point commit directly.
160
+ app.post('/api/auto-accept', (req, res) => {
161
+ try {
162
+ const filename = req.body?.filename;
163
+ const enabled = req.body?.enabled === true;
164
+ if (!filename)
165
+ return res.status(400).json({ error: 'filename required' });
166
+ const isActiveDoc = filename === getActiveFilename();
167
+ if (isActiveDoc) {
168
+ if (enabled) {
169
+ stripPendingAttrs(); // accept any pending changes
170
+ setMetadata({ autoAccept: true });
171
+ }
172
+ else {
173
+ const meta = getMetadata();
174
+ delete meta.autoAccept;
175
+ }
176
+ save();
177
+ updatePendingCacheForActiveDoc();
178
+ broadcastMetadataChanged(getMetadata());
179
+ broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename(), getMetadata());
180
+ }
181
+ else {
182
+ if (enabled)
183
+ stripPendingAttrsFromFile(filename, true);
184
+ setAutoAcceptOnFile(filename, enabled);
185
+ }
186
+ broadcastDocumentsChanged();
187
+ broadcastPendingDocsChanged();
188
+ res.json({ success: true });
189
+ }
190
+ catch (err) {
191
+ res.status(500).json({ error: err.message });
192
+ }
193
+ });
157
194
  app.post('/api/save', (_req, res) => {
158
195
  save();
159
196
  res.json({ success: true });
@@ -400,6 +400,17 @@ function inlineTokensToTiptap(tokens) {
400
400
  if (/^<br\s*\/?>$/i.test(token.content.trim())) {
401
401
  nodes.push({ type: 'hardBreak' });
402
402
  }
403
+ else {
404
+ // Preserve raw HTML-looking text so user content isn't silently dropped.
405
+ // On serialize, escapeInlineHtml converts `<` to `&lt;`; on the next
406
+ // parse markdown-it decodes `&lt;` back to a single text token, so the
407
+ // round-trip is stable after the first save.
408
+ const textNode = { type: 'text', text: token.content };
409
+ if (markStack.length > 0) {
410
+ textNode.marks = deduplicateMarks(markStack);
411
+ }
412
+ nodes.push(textNode);
413
+ }
403
414
  }
404
415
  else if (token.type === 'hardbreak') {
405
416
  nodes.push({ type: 'hardBreak' });
@@ -237,7 +237,10 @@ export function inlineToMarkdown(nodes) {
237
237
  for (let i = commonLen; i < targetMarks.length; i++) {
238
238
  result += markSyntax(targetMarks[i], true);
239
239
  }
240
- result += escapeInlineHtml(node.text || '');
240
+ // Skip HTML escape for text inside inline code — CommonMark treats
241
+ // backtick spans as verbatim, so `&lt;` would render literally.
242
+ const hasCodeMark = (node.marks || []).some((m) => m.type === 'code');
243
+ result += hasCodeMark ? (node.text || '') : escapeInlineHtml(node.text || '');
241
244
  openMarks = [...targetMarks];
242
245
  }
243
246
  // Close remaining marks
@@ -221,6 +221,9 @@ export const TOOL_REGISTRY = [
221
221
  pendingChanges: target.pendingCount,
222
222
  lastModified: target.lastModified.toISOString(),
223
223
  };
224
+ // Surface autoAccept so the agent stops waiting for review when it's on.
225
+ if (target.metadata?.autoAccept === true)
226
+ status.autoAccept = true;
224
227
  const latestVersion = getUpdateInfo();
225
228
  const payload = latestVersion ? { ...status, updateAvailable: latestVersion } : status;
226
229
  return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
@@ -410,9 +413,12 @@ export const TOOL_REGISTRY = [
410
413
  }],
411
414
  };
412
415
  }
413
- // Active target (or no filename): existing flow
416
+ // Active target (or no filename): existing flow.
417
+ // Skip pending tagging when autoAccept is on for this doc — content commits directly.
414
418
  setAgentLock(); // Block browser doc-updates during population
415
- markAllNodesAsPending(doc, 'insert');
419
+ if (getMetadata()?.autoAccept !== true) {
420
+ markAllNodesAsPending(doc, 'insert');
421
+ }
416
422
  updateDocument(doc);
417
423
  updatePendingCacheForActiveDoc();
418
424
  save();
@@ -559,8 +559,13 @@ function findNodeInDoc(nodes, id) {
559
559
  /**
560
560
  * Core change application logic — operates on any document object.
561
561
  * Mutates doc in place and returns processed changes with server-assigned IDs.
562
+ *
563
+ * When `autoAccept` is true, changes commit directly: no pendingStatus tagging,
564
+ * no pendingOriginalContent baseline, and deletes hard-remove from the array
565
+ * (rather than tagging for review). Processed changes carry autoAccept: true
566
+ * so the client knows to apply them as committed edits, not pending review.
562
567
  */
563
- function applyChangesToDoc(doc, changes) {
568
+ function applyChangesToDoc(doc, changes, autoAccept = false) {
564
569
  const processed = [];
565
570
  // Track last insert anchor → last inserted node ID, so consecutive inserts
566
571
  // with the same afterNodeId chain naturally (array order = document order).
@@ -581,16 +586,21 @@ function applyChangesToDoc(doc, changes) {
581
586
  // Detect partial change: if only a sub-range of the node text changed,
582
587
  // attach selection range attrs so the frontend decorates only that part
583
588
  let partialRange = null;
584
- if (!isEmptyNode && contentArray.length === 1) {
589
+ if (!isEmptyNode && contentArray.length === 1 && !autoAccept) {
585
590
  // Use true original for partial range when a prior pending rewrite exists,
586
591
  // so offsets align with pendingOriginalContent
587
592
  const baseContent = existingOriginal?.content || originalNode.content || [];
588
593
  partialRange = computePartialRange(baseContent, contentArray[0].content || []);
589
594
  }
590
- // First node replaces the target (rewrite or insert if empty)
595
+ // First node replaces the target (rewrite or insert if empty).
596
+ // In autoAccept mode, omit all pendingStatus/pendingOriginalContent attrs
597
+ // so the change commits cleanly with no review surface.
591
598
  const firstNode = {
592
599
  ...contentArray[0],
593
- attrs: {
600
+ attrs: autoAccept ? {
601
+ ...contentArray[0].attrs,
602
+ id: change.nodeId,
603
+ } : {
594
604
  ...contentArray[0].attrs,
595
605
  id: change.nodeId,
596
606
  pendingStatus: isEmptyNode ? 'insert' : 'rewrite',
@@ -603,7 +613,8 @@ function applyChangesToDoc(doc, changes) {
603
613
  } : {}),
604
614
  },
605
615
  };
606
- // Additional nodes get inserted after as pending inserts
616
+ // Additional nodes get inserted after as pending inserts in normal mode,
617
+ // as plain blocks in autoAccept mode.
607
618
  const extraNodes = contentArray.slice(1).map((node) => ({
608
619
  ...node,
609
620
  attrs: {
@@ -611,11 +622,13 @@ function applyChangesToDoc(doc, changes) {
611
622
  id: node.attrs?.id || generateNodeId(),
612
623
  },
613
624
  }));
614
- markLeafBlocksAsPending(extraNodes, 'insert');
625
+ if (!autoAccept)
626
+ markLeafBlocksAsPending(extraNodes, 'insert');
615
627
  found.parent.splice(found.index, 1, firstNode, ...extraNodes);
616
628
  processed.push({
617
629
  ...change,
618
630
  content: [firstNode, ...extraNodes],
631
+ ...(autoAccept ? { autoAccept: true } : {}),
619
632
  });
620
633
  }
621
634
  else if (change.operation === 'insert' && change.content) {
@@ -628,8 +641,10 @@ function applyChangesToDoc(doc, changes) {
628
641
  id: node.attrs?.id || (change.nodeId && !change.afterNodeId && i === 0 ? change.nodeId : generateNodeId()),
629
642
  },
630
643
  }));
631
- // Mark leaf blocks as pending (not containers) for correct serialization
632
- markLeafBlocksAsPending(contentWithIds, 'insert');
644
+ // Mark leaf blocks as pending (not containers) skipped in autoAccept mode
645
+ // so inserts commit as plain content without decoration.
646
+ if (!autoAccept)
647
+ markLeafBlocksAsPending(contentWithIds, 'insert');
633
648
  let resolvedAfterId;
634
649
  // Auto-chain: if this insert targets the same anchor as the previous insert,
635
650
  // redirect it to insert after the last inserted node instead (preserves array order).
@@ -671,6 +686,7 @@ function applyChangesToDoc(doc, changes) {
671
686
  ? resolvedAfterId
672
687
  : effectiveAfterId ?? change.afterNodeId,
673
688
  content: contentWithIds.length === 1 ? contentWithIds[0] : contentWithIds,
689
+ ...(autoAccept ? { autoAccept: true } : {}),
674
690
  });
675
691
  }
676
692
  else if (change.operation === 'delete' && change.nodeId) {
@@ -698,21 +714,29 @@ function applyChangesToDoc(doc, changes) {
698
714
  processed.push({ operation: 'delete', nodeId: change.nodeId, content: [{ type: 'horizontalRule' }] });
699
715
  continue;
700
716
  }
701
- found.parent[found.index] = {
702
- ...found.parent[found.index],
703
- attrs: {
704
- ...found.parent[found.index].attrs,
705
- pendingStatus: 'delete',
706
- },
707
- };
708
- processed.push(change);
717
+ if (autoAccept) {
718
+ // Hard-delete: remove the node entirely from its parent array.
719
+ found.parent.splice(found.index, 1);
720
+ processed.push({ ...change, autoAccept: true });
721
+ }
722
+ else {
723
+ found.parent[found.index] = {
724
+ ...found.parent[found.index],
725
+ attrs: {
726
+ ...found.parent[found.index].attrs,
727
+ pendingStatus: 'delete',
728
+ },
729
+ };
730
+ processed.push(change);
731
+ }
709
732
  }
710
733
  }
711
734
  return processed;
712
735
  }
713
736
  /** Apply changes to the active document singleton. */
714
737
  function applyChangesToDocument(changes) {
715
- const processed = applyChangesToDoc(state.document, changes);
738
+ const autoAccept = state.metadata?.autoAccept === true;
739
+ const processed = applyChangesToDoc(state.document, changes, autoAccept);
716
740
  if (processed.length > 0) {
717
741
  state.lastModified = new Date();
718
742
  }
@@ -730,11 +754,13 @@ export function applyTextEdits(nodeId, edits) {
730
754
  const result = applyTextEditsToNode(originalNode, edits);
731
755
  if (!result)
732
756
  return { success: false, error: 'No edits matched' };
733
- // Store inline edit ranges for fine-grained decoration
734
- result.node.attrs = {
735
- ...result.node.attrs,
736
- pendingTextEdits: result.textEdits,
737
- };
757
+ // Inline edit decoration only matters when there's a review surface — skip in autoAccept.
758
+ if (state.metadata?.autoAccept !== true) {
759
+ result.node.attrs = {
760
+ ...result.node.attrs,
761
+ pendingTextEdits: result.textEdits,
762
+ };
763
+ }
738
764
  // Route through applyChanges as a rewrite so it goes through the normal pipeline
739
765
  applyChanges([{
740
766
  operation: 'rewrite',
@@ -1369,6 +1395,39 @@ export function saveDocToFile(filename, doc) {
1369
1395
  }
1370
1396
  catch { /* best-effort */ }
1371
1397
  }
1398
+ /**
1399
+ * Set or clear the autoAccept flag on a non-active document file on disk.
1400
+ * Reads the file, mutates metadata, writes back. Does not touch pending attrs —
1401
+ * callers should run stripPendingAttrsFromFile first when enabling.
1402
+ */
1403
+ export function setAutoAcceptOnFile(filename, enabled) {
1404
+ const targetPath = resolveDocPath(filename);
1405
+ if (!existsSync(targetPath))
1406
+ return;
1407
+ try {
1408
+ const raw = readFileSync(targetPath, 'utf-8');
1409
+ const parsed = markdownToTiptap(raw);
1410
+ if (enabled) {
1411
+ parsed.metadata.autoAccept = true;
1412
+ }
1413
+ else {
1414
+ delete parsed.metadata.autoAccept;
1415
+ }
1416
+ let markdown;
1417
+ if (isExternalDoc(targetPath)) {
1418
+ const body = tiptapToBody(parsed.document);
1419
+ markdown = parsed.rawFrontmatter
1420
+ ? `---\n${parsed.rawFrontmatter}\n---\n\n${body}`
1421
+ : body;
1422
+ }
1423
+ else {
1424
+ markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
1425
+ }
1426
+ atomicWriteFileSync(targetPath, markdown);
1427
+ invalidateDocCache(targetPath);
1428
+ }
1429
+ catch { /* best-effort */ }
1430
+ }
1372
1431
  /**
1373
1432
  * Strip pending attrs from a specific file on disk (not the active document).
1374
1433
  * Optionally clears agentCreated metadata (on accept).
@@ -1442,7 +1501,11 @@ export function populateDocumentFile(filename, doc) {
1442
1501
  const targetPath = resolveDocPath(filename);
1443
1502
  const raw = readFileSync(targetPath, 'utf-8');
1444
1503
  const parsed = markdownToTiptap(raw);
1445
- markAllNodesAsPending(doc, 'insert');
1504
+ // Skip pending tagging when the target doc has autoAccept on —
1505
+ // content commits directly as accepted.
1506
+ if (parsed.metadata?.autoAccept !== true) {
1507
+ markAllNodesAsPending(doc, 'insert');
1508
+ }
1446
1509
  flushDocToFile(filename, doc, parsed.title, parsed.metadata);
1447
1510
  const pendingCount = countPending(doc.content);
1448
1511
  const text = extractText(doc.content);
@@ -1478,7 +1541,8 @@ export function applyChangesToFile(filename, changes) {
1478
1541
  docId = metadata.docId || '';
1479
1542
  isTemp = false;
1480
1543
  }
1481
- const processed = applyChangesToDoc(doc, changes);
1544
+ const autoAccept = metadata?.autoAccept === true;
1545
+ const processed = applyChangesToDoc(doc, changes, autoAccept);
1482
1546
  if (processed.length > 0) {
1483
1547
  flushDocToFile(filename, doc, title, metadata);
1484
1548
  updateCacheEntry(targetPath, doc, title, metadata, isTemp, docId);
@@ -1533,16 +1597,21 @@ export function applyTextEditsToFile(filename, nodeId, edits) {
1533
1597
  const result = applyTextEditsToNode(originalNode, edits);
1534
1598
  if (!result)
1535
1599
  return { success: false, error: 'No edits matched' };
1536
- result.node.attrs = {
1537
- ...result.node.attrs,
1538
- pendingTextEdits: result.textEdits,
1539
- };
1600
+ const autoAccept = metadata?.autoAccept === true;
1601
+ // pendingTextEdits is the fine-grained inline-edit decoration — skip in autoAccept
1602
+ // since the change commits directly.
1603
+ if (!autoAccept) {
1604
+ result.node.attrs = {
1605
+ ...result.node.attrs,
1606
+ pendingTextEdits: result.textEdits,
1607
+ };
1608
+ }
1540
1609
  // Apply as a rewrite to the doc
1541
1610
  const processed = applyChangesToDoc(doc, [{
1542
1611
  operation: 'rewrite',
1543
1612
  nodeId,
1544
1613
  content: result.node,
1545
- }]);
1614
+ }], autoAccept);
1546
1615
  if (processed.length > 0) {
1547
1616
  flushDocToFile(filename, doc, title, metadata);
1548
1617
  updateCacheEntry(targetPath, doc, title, metadata, isTemp, docId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
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",
package/skill/SKILL.md CHANGED
@@ -16,7 +16,7 @@ description: |
16
16
  Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
17
17
  metadata:
18
18
  author: travsteward
19
- version: "0.5.0"
19
+ version: "0.6.0"
20
20
  repository: https://github.com/travsteward/openwriter
21
21
  license: MIT
22
22
  ---
@@ -208,6 +208,16 @@ For making changes to existing documents — rewrites, insertions, deletions:
208
208
  - Decoration colors: **blue** = rewrite, **green** = insert, **red** = delete
209
209
  - **Never re-populate a document to fix it.** `populate_document` re-sends the entire document body — extremely token-expensive. To remove nodes, use `write_to_pad` with `{ operation: "delete", nodeId: "..." }`. To fix content, use `rewrite`. Only use `populate_document` once during initial creation, or as a last resort if the document is severely broken.
210
210
 
211
+ ### Auto-accept mode (no pending review)
212
+
213
+ The user can turn on **auto-accept** on a per-doc basis (right-click the doc in the sidebar). When on, your edits commit directly — no pending decorations, no review panel for that doc. Used during fast drafting where the user isn't reviewing as you go.
214
+
215
+ - `get_pad_status` returns `autoAccept: true` when the active doc has it on. Use this to decide your cadence.
216
+ - **When autoAccept is true:** keep writing without polling for review. Don't wait between batches. Send the next 3-8 changes the moment you're ready.
217
+ - **When autoAccept is false (default):** respect `pendingChanges > 0` — wait for the user to accept/reject before sending more.
218
+ - You don't toggle this flag yourself — only the user does, from the sidebar. If you think the user wants it, ask first.
219
+ - The flag is persisted in the doc's frontmatter as `autoAccept: true`. Visible in `get_metadata`.
220
+
211
221
  ### Creating New Documents (two-step flow)
212
222
 
213
223
  **Always use the two-step flow** when creating new content:
@@ -1,41 +0,0 @@
1
- /**
2
- * Author's Voice plugin for OpenWriter.
3
- * Proxies /api/voice/* to the AV backend and adds context menu items
4
- * for rewriting, shrinking, expanding, and custom instructions.
5
- * Also registers sidebar menu items for document-level transforms.
6
- */
7
- import type { Express } from 'express';
8
- interface PluginConfigField {
9
- type: 'string' | 'number' | 'boolean';
10
- required?: boolean;
11
- env?: string;
12
- description?: string;
13
- }
14
- interface PluginRouteContext {
15
- app: Express;
16
- config: Record<string, string>;
17
- }
18
- interface PluginContextMenuItem {
19
- label: string;
20
- shortcut?: string;
21
- action: string;
22
- condition?: 'has-selection' | 'empty-node' | 'always';
23
- promptForInput?: boolean;
24
- }
25
- interface PluginSidebarMenuItem {
26
- label: string;
27
- action: string;
28
- promptForFocus?: boolean;
29
- }
30
- interface OpenWriterPlugin {
31
- name: string;
32
- version: string;
33
- description?: string;
34
- category?: 'writing' | 'social-media' | 'image-generation';
35
- configSchema?: Record<string, PluginConfigField>;
36
- registerRoutes?(ctx: PluginRouteContext): void | Promise<void>;
37
- contextMenuItems?(): PluginContextMenuItem[];
38
- sidebarMenuItems?(): PluginSidebarMenuItem[];
39
- }
40
- declare const plugin: OpenWriterPlugin;
41
- export default plugin;
@@ -1,206 +0,0 @@
1
- /**
2
- * Author's Voice plugin for OpenWriter.
3
- * Proxies /api/voice/* to the AV backend and adds context menu items
4
- * for rewriting, shrinking, expanding, and custom instructions.
5
- * Also registers sidebar menu items for document-level transforms.
6
- */
7
- /** Simple HTML → markdown conversion for document creation */
8
- function htmlToMarkdown(html) {
9
- let md = html;
10
- // <hr> → horizontal rule
11
- md = md.replace(/<hr\s*\/?>/gi, '\n---\n');
12
- // <br> → newline
13
- md = md.replace(/<br\s*\/?>/gi, '\n');
14
- // <strong>/<b> → **bold**
15
- md = md.replace(/<(strong|b)>([\s\S]*?)<\/\1>/gi, '**$2**');
16
- // <em>/<i> → *italic*
17
- md = md.replace(/<(em|i)>([\s\S]*?)<\/\1>/gi, '*$2*');
18
- // <p> → paragraph boundaries
19
- md = md.replace(/<p[^>]*>/gi, '');
20
- md = md.replace(/<\/p>/gi, '\n\n');
21
- // Strip remaining tags
22
- md = md.replace(/<[^>]+>/g, '');
23
- // Normalize whitespace
24
- md = md.replace(/\n{3,}/g, '\n\n');
25
- return md.trim();
26
- }
27
- const plugin = {
28
- name: '@openwriter/plugin-authors-voice',
29
- version: '0.1.0',
30
- description: "Rewrite text in your voice using Author's Voice",
31
- category: 'writing',
32
- configSchema: {
33
- 'api-key': {
34
- type: 'string',
35
- required: true,
36
- env: 'AV_API_KEY',
37
- description: 'Author\'s Voice API key',
38
- },
39
- 'backend-url': {
40
- type: 'string',
41
- env: 'AV_BACKEND_URL',
42
- description: 'AV backend URL',
43
- },
44
- },
45
- registerRoutes(ctx) {
46
- const backendUrl = ctx.config['backend-url'] || process.env.AV_BACKEND_URL || 'https://authors-voice.com';
47
- const apiKey = ctx.config['api-key'] || process.env.AV_API_KEY || '';
48
- const authHeaders = () => {
49
- const h = { 'Content-Type': 'application/json' };
50
- if (apiKey)
51
- h['Authorization'] = `Bearer ${apiKey}`;
52
- return h;
53
- };
54
- // Sidebar action handler — must be registered BEFORE the wildcard
55
- ctx.app.post('/api/voice/sidebar-action', async (req, res) => {
56
- try {
57
- const { action, filename, title, instructions, content } = req.body;
58
- console.log(`[AV Plugin] Sidebar action: ${action} on "${title}"`);
59
- if (!content) {
60
- res.status(400).json({ error: 'Document content is required' });
61
- return;
62
- }
63
- // Call AV backend transform endpoint
64
- const transformUrl = `${backendUrl}/api/voice/transform`;
65
- const upstream = await fetch(transformUrl, {
66
- method: 'POST',
67
- headers: authHeaders(),
68
- body: JSON.stringify({ action, content, title, instructions }),
69
- });
70
- if (!upstream.ok) {
71
- const errData = await upstream.json().catch(() => ({}));
72
- console.error('[AV Plugin] Transform failed:', upstream.status, errData);
73
- res.status(upstream.status).json(errData);
74
- return;
75
- }
76
- const transformResult = await upstream.json();
77
- // Convert HTML output to markdown for document creation
78
- let markdownContent = htmlToMarkdown(transformResult.html);
79
- // Threadify: always create as tweet template
80
- const createBody = {
81
- title: transformResult.newTitle,
82
- content: markdownContent,
83
- markPending: true,
84
- agentCreated: true,
85
- };
86
- if (action === 'threadify') {
87
- // Build TipTap JSON directly to avoid markdown parsing issues.
88
- // Markdown parser converts "- item" lines to bulletList nodes that the
89
- // tweet editor can't render (bulletList extension is disabled), causing
90
- // empty gaps. By building JSON with only paragraph + hardBreak nodes,
91
- // all tweet text stays as plain text.
92
- if (transformResult.thread?.tweets?.length) {
93
- const docContent = [];
94
- transformResult.thread.tweets.forEach((t, i) => {
95
- // Single paragraph per tweet. Split on \n only:
96
- // \n → one hardBreak (tight line), \n\n → two hardBreaks (blank line spacing)
97
- const lines = t.text.split('\n');
98
- const nodes = [];
99
- lines.forEach((line, j) => {
100
- if (j > 0)
101
- nodes.push({ type: 'hardBreak' });
102
- if (line)
103
- nodes.push({ type: 'text', text: line });
104
- });
105
- if (nodes.length) {
106
- docContent.push({ type: 'paragraph', content: nodes });
107
- }
108
- if (i < transformResult.thread.tweets.length - 1) {
109
- docContent.push({ type: 'horizontalRule' });
110
- }
111
- });
112
- createBody.content = { type: 'doc', content: docContent };
113
- }
114
- createBody.metadata = { tweetContext: { mode: 'tweet' } };
115
- }
116
- // Create new document in OpenWriter via internal HTTP call
117
- const host = req.get('host') || 'localhost:5050';
118
- const protocol = req.protocol || 'http';
119
- const createUrl = `${protocol}://${host}/api/documents`;
120
- const createRes = await fetch(createUrl, {
121
- method: 'POST',
122
- headers: { 'Content-Type': 'application/json' },
123
- body: JSON.stringify(createBody),
124
- });
125
- if (!createRes.ok) {
126
- const errData = await createRes.json().catch(() => ({}));
127
- console.error('[AV Plugin] Document creation failed:', errData);
128
- res.status(500).json({ error: 'Failed to create result document' });
129
- return;
130
- }
131
- const docResult = await createRes.json();
132
- res.json({
133
- success: true,
134
- action,
135
- filename: docResult.filename,
136
- title: transformResult.newTitle,
137
- metadata: transformResult.metadata,
138
- });
139
- }
140
- catch (err) {
141
- console.error('[AV Plugin] Sidebar action error:', err?.message || err);
142
- res.status(500).json({ error: 'Sidebar action failed' });
143
- }
144
- });
145
- // Wildcard proxy for all other /api/voice/* routes
146
- ctx.app.post('/api/voice/*', async (req, res) => {
147
- try {
148
- const subPath = req.params[0] || '';
149
- const targetUrl = `${backendUrl}/api/voice/${subPath}`;
150
- console.log(`[AV Plugin] ${req.method} ${req.path} → ${targetUrl}`);
151
- const upstream = await fetch(targetUrl, {
152
- method: 'POST',
153
- headers: authHeaders(),
154
- body: JSON.stringify(req.body),
155
- });
156
- res.status(upstream.status);
157
- const forwardHeaders = ['x-usage-rewrite-count', 'x-usage-rewrite-limit', 'x-usage-resets-at'];
158
- for (const h of forwardHeaders) {
159
- const val = upstream.headers.get(h);
160
- if (val)
161
- res.setHeader(h, val);
162
- }
163
- const responseText = await upstream.text();
164
- try {
165
- const data = JSON.parse(responseText);
166
- res.json(data);
167
- }
168
- catch {
169
- console.error('[AV Plugin] Non-JSON response:', responseText.substring(0, 500));
170
- res.status(502).json({ error: 'AV backend returned non-JSON response' });
171
- }
172
- }
173
- catch (err) {
174
- console.error('[AV Plugin] Backend error:', err?.message || err);
175
- res.status(502).json({ error: 'AV backend unreachable' });
176
- }
177
- });
178
- },
179
- contextMenuItems() {
180
- return [
181
- // Selection actions (require highlighted text)
182
- { label: 'Enhance', shortcut: 'R', action: 'av:rewrite', condition: 'has-selection' },
183
- { label: 'Modify...', action: 'av:custom', condition: 'has-selection', promptForInput: true },
184
- { label: 'Shrink', shortcut: 'S', action: 'av:shrink', condition: 'has-selection' },
185
- { label: 'Expand', shortcut: 'E', action: 'av:expand', condition: 'has-selection' },
186
- // Empty node actions (cursor on empty line)
187
- { label: 'Insert', shortcut: 'I', action: 'av:insert', condition: 'empty-node', promptForInput: true },
188
- { label: 'Fill paragraph', shortcut: 'F', action: 'av:fill', condition: 'empty-node' },
189
- { label: 'Fill sentence', action: 'av:fill-sentence', condition: 'empty-node' },
190
- ];
191
- },
192
- // Sidebar transforms disabled — now handled by publish plugin.
193
- // Kept commented for reference during transition.
194
- // sidebarMenuItems() {
195
- // return [
196
- // { label: 'Vary', action: 'voice:vary', promptForFocus: true },
197
- // { label: 'Shrinkify', action: 'voice:shrinkify', promptForFocus: true },
198
- // { label: 'Expandify', action: 'voice:expandify', promptForFocus: true },
199
- // { label: 'Threadify', action: 'voice:threadify', promptForFocus: true },
200
- // { label: 'Storify', action: 'voice:storify', promptForFocus: true },
201
- // { label: 'Emailify', action: 'voice:emailify', promptForFocus: true },
202
- // { label: 'Postify', action: 'voice:postify', promptForFocus: true },
203
- // ];
204
- // },
205
- };
206
- export default plugin;