openwriter 0.27.0 → 0.28.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-DgUPw-v5.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-BJMpYpj1.css">
13
+ <script type="module" crossorigin src="/assets/index-DzHT4klX.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-3no79ry9.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -1,8 +1,10 @@
1
1
  /**
2
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.
3
+ * Proxies /api/voice/* to the AV backend and adds editor context-menu items
4
+ * for sub-paragraph text actions (Enhance / Modify / Shrink / Expand / Insert / Fill).
5
+ *
6
+ * Sidebar document transforms (Vary / Shrinkify / Threadify / etc.) live in
7
+ * @openwriter/plugin-publish — they go through the metered platform path.
6
8
  */
7
9
  import type { Express } from 'express';
8
10
  interface PluginConfigField {
@@ -22,11 +24,6 @@ interface PluginContextMenuItem {
22
24
  condition?: 'has-selection' | 'empty-node' | 'always';
23
25
  promptForInput?: boolean;
24
26
  }
25
- interface PluginSidebarMenuItem {
26
- label: string;
27
- action: string;
28
- promptForFocus?: boolean;
29
- }
30
27
  interface OpenWriterPlugin {
31
28
  name: string;
32
29
  version: string;
@@ -35,7 +32,6 @@ interface OpenWriterPlugin {
35
32
  configSchema?: Record<string, PluginConfigField>;
36
33
  registerRoutes?(ctx: PluginRouteContext): void | Promise<void>;
37
34
  contextMenuItems?(): PluginContextMenuItem[];
38
- sidebarMenuItems?(): PluginSidebarMenuItem[];
39
35
  }
40
36
  declare const plugin: OpenWriterPlugin;
41
37
  export default plugin;
@@ -1,32 +1,14 @@
1
1
  /**
2
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.
3
+ * Proxies /api/voice/* to the AV backend and adds editor context-menu items
4
+ * for sub-paragraph text actions (Enhance / Modify / Shrink / Expand / Insert / Fill).
5
+ *
6
+ * Sidebar document transforms (Vary / Shrinkify / Threadify / etc.) live in
7
+ * @openwriter/plugin-publish — they go through the metered platform path.
6
8
  */
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
9
  const plugin = {
28
10
  name: '@openwriter/plugin-authors-voice',
29
- version: '0.1.0',
11
+ version: '0.4.0',
30
12
  description: "Rewrite text in your voice using Author's Voice",
31
13
  category: 'writing',
32
14
  configSchema: {
@@ -45,113 +27,31 @@ const plugin = {
45
27
  registerRoutes(ctx) {
46
28
  const backendUrl = ctx.config['backend-url'] || process.env.AV_BACKEND_URL || 'https://authors-voice.com';
47
29
  const apiKey = ctx.config['api-key'] || process.env.AV_API_KEY || '';
30
+ const debugEnabled = process.env.AV_DEBUG === '1' || process.env.AV_DEBUG === 'true';
48
31
  const authHeaders = () => {
49
32
  const h = { 'Content-Type': 'application/json' };
50
33
  if (apiKey)
51
34
  h['Authorization'] = `Bearer ${apiKey}`;
52
35
  return h;
53
36
  };
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
37
+ const withDebug = (body) => {
38
+ if (!debugEnabled || !body || typeof body !== 'object')
39
+ return body;
40
+ return { ...body, debug: true };
41
+ };
42
+ // Wildcard proxy for /api/voice/* routes. Pure pass-through: the AV API owns the
43
+ // engine choice (v1/v2) via its own AV_DEFAULT_ENGINE setting, so the plugin injects
44
+ // nothing but the optional owner-only dev debug flag.
146
45
  ctx.app.post('/api/voice/*', async (req, res) => {
147
46
  try {
148
47
  const subPath = req.params[0] || '';
149
48
  const targetUrl = `${backendUrl}/api/voice/${subPath}`;
49
+ const body = withDebug(req.body);
150
50
  console.log(`[AV Plugin] ${req.method} ${req.path} → ${targetUrl}`);
151
51
  const upstream = await fetch(targetUrl, {
152
52
  method: 'POST',
153
53
  headers: authHeaders(),
154
- body: JSON.stringify(req.body),
54
+ body: JSON.stringify(body),
155
55
  });
156
56
  res.status(upstream.status);
157
57
  const forwardHeaders = ['x-usage-rewrite-count', 'x-usage-rewrite-limit', 'x-usage-resets-at'];
@@ -189,18 +89,5 @@ const plugin = {
189
89
  { label: 'Fill sentence', action: 'av:fill-sentence', condition: 'empty-node' },
190
90
  ];
191
91
  },
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
92
  };
206
93
  export default plugin;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Manual-post → autoplug enrollment bridge.
3
+ *
4
+ * API/scheduler posts enter the platform's autoplug tracking at post time
5
+ * (the platform calls autoTrackTweet on /connections/:id/post, post-thread,
6
+ * the scheduler cron, and on POST /publications). Manual posts only ever
7
+ * wrote local `tweetContext.lastPost` frontmatter and never told the platform,
8
+ * so they never enrolled — and quote tweets, which the X API cannot post and
9
+ * therefore ALWAYS go through manual mark-sent, could never be autoplug-tracked.
10
+ *
11
+ * This reconciles the two: when a tweet/article doc is marked posted with a
12
+ * tweet URL, we record a publication on the platform. The platform's
13
+ * POST /publications handler enrolls X publications into autoplug tracking,
14
+ * so engagement-threshold autoplugs now fire on manually-posted tweets the
15
+ * same way they do for API/scheduler posts.
16
+ */
17
+ import { isAuthenticated, platformFetch } from './connections.js';
18
+ const TWEET_ID_RE = /(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/i;
19
+ /** Pull the numeric tweet id out of an x.com / twitter.com status URL. */
20
+ export function extractTweetId(url) {
21
+ if (!url)
22
+ return null;
23
+ const m = TWEET_ID_RE.exec(url);
24
+ return m ? m[1] : null;
25
+ }
26
+ /**
27
+ * Enroll a manually-posted tweet into platform autoplug tracking.
28
+ *
29
+ * Best-effort and idempotent: skips when the tweet is already recorded as a
30
+ * publication for this doc, so re-marking, unmark/remark, and autosave churn
31
+ * never duplicate the enrollment. Never throws — enrollment must never block
32
+ * or break the mark-sent metadata write.
33
+ */
34
+ export async function enrollManualPostForAutoplug(docId, tweetUrl, text) {
35
+ if (!isAuthenticated())
36
+ return;
37
+ const tweetId = extractTweetId(tweetUrl);
38
+ if (!tweetId)
39
+ return;
40
+ try {
41
+ // Idempotency: the platform's publications table has no unique constraint,
42
+ // so dedupe here before recording another row + re-enrolling.
43
+ const existingRes = await platformFetch(`/publications?documentId=${encodeURIComponent(docId)}`);
44
+ if (existingRes.ok) {
45
+ const data = await existingRes.json();
46
+ const already = (data.publications || []).some((p) => p.platform === 'x' && p.external_id === tweetId);
47
+ if (already)
48
+ return;
49
+ }
50
+ // Recording the publication triggers autoTrackTweet platform-side, which
51
+ // enrolls the tweet for autoplug rule evaluation (guarded there by the
52
+ // profile having ≥1 enabled goal + an active X connection).
53
+ const res = await platformFetch('/publications', {
54
+ method: 'POST',
55
+ body: JSON.stringify({
56
+ document_id: docId,
57
+ platform: 'x',
58
+ external_id: tweetId,
59
+ url: tweetUrl,
60
+ meta: { text: text || '', last_tweet_id: tweetId },
61
+ }),
62
+ });
63
+ if (res.ok) {
64
+ console.log(`[Autoplug] Enrolled manual post ${tweetId} (doc ${docId}) into tracking`);
65
+ }
66
+ }
67
+ catch {
68
+ // Best-effort: platform may be unreachable / unauthenticated. Mark-sent
69
+ // already succeeded locally; tracking simply won't enroll this time.
70
+ }
71
+ }
@@ -11,8 +11,9 @@ 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, stripPendingAttrs, stripPendingAttrsFromFile, setAutoAcceptOnFile, setSortRequestOnFile, clearSortRequestOnFile, bumpDocVersion, markAsAgentStub } 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, setSortRequestOnFile, clearSortRequestOnFile, bumpDocVersion, markAsAgentStub, extractText } from './state.js';
15
15
  import { syncPostHistory } from './post-sync.js';
16
+ import { enrollManualPostForAutoplug } from './autoplug-enroll.js';
16
17
  import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename, batchResolve, listPendingSorts } from './documents.js';
17
18
  import { createWorkspaceRouter } from './workspace-routes.js';
18
19
  import { createLinkRouter } from './link-routes.js';
@@ -162,10 +163,27 @@ export async function startHttpServer(options = {}) {
162
163
  // Update document metadata from browser (e.g. view toggle in Appearance panel)
163
164
  app.post('/api/metadata', (req, res) => {
164
165
  try {
165
- setMetadata(req.body);
166
+ const body = req.body || {};
167
+ setMetadata(body);
166
168
  save();
167
169
  broadcastMetadataChanged(getMetadata());
168
170
  broadcastDocumentsChanged();
171
+ // Reconcile manual mark-sent with autoplug tracking. When this write
172
+ // marks a tweet/article posted with a tweet URL, enroll the tweet so
173
+ // engagement autoplugs fire on manual posts — including quote tweets,
174
+ // which the X API can't post and so always reach here, never the API
175
+ // path that already enrolls. Capture docId/text synchronously (this is
176
+ // the active doc) then fire-and-forget; enrollment never blocks the save.
177
+ // Honors the per-doc opt-out: `autoplug: false` skips enrollment entirely
178
+ // (the manual lane just makes no platform call).
179
+ const tweetUrl = body?.tweetContext?.lastPost?.tweetUrl || body?.articleContext?.lastPost?.tweetUrl;
180
+ if (tweetUrl && getMetadata()?.autoplug !== false) {
181
+ const docId = getDocId();
182
+ if (docId) {
183
+ const text = extractText(getDocument()?.content || []);
184
+ void enrollManualPostForAutoplug(docId, tweetUrl, text);
185
+ }
186
+ }
169
187
  res.json({ success: true });
170
188
  }
171
189
  catch (err) {
@@ -4,6 +4,7 @@
4
4
  import { Router } from 'express';
5
5
  import { platformFetch, isAuthenticated } from './connections.js';
6
6
  import { syncPostHistory } from './post-sync.js';
7
+ import { getMetadata } from './state.js';
7
8
  export function createSchedulerRouter() {
8
9
  const router = Router();
9
10
  function proxy(path, method = 'GET') {
@@ -74,7 +75,34 @@ export function createSchedulerRouter() {
74
75
  });
75
76
  // Queue
76
77
  router.get('/api/scheduler/queue', proxy('/scheduler/queue'));
77
- router.post('/api/scheduler/queue', proxy('/scheduler/queue', 'POST'));
78
+ router.post('/api/scheduler/queue', async (req, res) => {
79
+ try {
80
+ if (!isAuthenticated()) {
81
+ res.json({ error: 'Not authenticated' });
82
+ return;
83
+ }
84
+ const body = { ...req.body };
85
+ // Honor the active doc's autoplug opt-out: fold no_autoplug into the
86
+ // content JSONB (normalizing a bare-string content to { text }) so the
87
+ // platform cron skips enrollment for this scheduled post.
88
+ if (getMetadata()?.autoplug === false) {
89
+ const c = body.content;
90
+ body.content = (c && typeof c === 'object')
91
+ ? { ...c, no_autoplug: true }
92
+ : { text: typeof c === 'string' ? c : '', no_autoplug: true };
93
+ }
94
+ const upstream = await platformFetch('/scheduler/queue', { method: 'POST', body: JSON.stringify(body) });
95
+ const data = await upstream.json();
96
+ if (!upstream.ok) {
97
+ res.status(upstream.status).json(data);
98
+ return;
99
+ }
100
+ res.json(data);
101
+ }
102
+ catch (err) {
103
+ res.status(500).json({ error: err.message });
104
+ }
105
+ });
78
106
  router.patch('/api/scheduler/queue/:id', async (req, res) => {
79
107
  try {
80
108
  if (!isAuthenticated()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.27.0",
3
+ "version": "0.28.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",