openwriter 0.9.3 → 0.11.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-wRyjoTwK.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-ZQ1BICWp.css">
13
+ <script type="module" crossorigin src="/assets/index-DCMxNd__.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-Cc-WcvZz.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -189,16 +189,18 @@ const plugin = {
189
189
  { label: 'Fill sentence', action: 'av:fill-sentence', condition: 'empty-node' },
190
190
  ];
191
191
  },
192
- sidebarMenuItems() {
193
- return [
194
- { label: 'Vary', action: 'voice:vary', promptForFocus: true },
195
- { label: 'Shrinkify', action: 'voice:shrinkify', promptForFocus: true },
196
- { label: 'Expandify', action: 'voice:expandify', promptForFocus: true },
197
- { label: 'Threadify', action: 'voice:threadify', promptForFocus: true },
198
- { label: 'Storify', action: 'voice:storify', promptForFocus: true },
199
- { label: 'Emailify', action: 'voice:emailify', promptForFocus: true },
200
- { label: 'Postify', action: 'voice:postify', promptForFocus: true },
201
- ];
202
- },
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
+ // },
203
205
  };
204
206
  export default plugin;
@@ -22,13 +22,25 @@ export interface PluginMcpTool {
22
22
  inputSchema: Record<string, unknown>;
23
23
  handler: (params: Record<string, unknown>) => Promise<unknown>;
24
24
  }
25
+ export interface PluginRouteContext {
26
+ app: import('express').Router;
27
+ config: Record<string, string>;
28
+ dataDir: string;
29
+ }
30
+ export interface PluginSidebarMenuItem {
31
+ label: string;
32
+ action: string;
33
+ promptForFocus?: boolean;
34
+ }
25
35
  export interface OpenWriterPlugin {
26
36
  name: string;
27
37
  version: string;
28
38
  description?: string;
29
39
  category?: 'writing' | 'social-media' | 'image-generation' | 'publishing' | 'productivity' | 'analytics';
30
40
  configSchema?: Record<string, PluginConfigField>;
41
+ registerRoutes?(ctx: PluginRouteContext): void | Promise<void>;
31
42
  mcpTools?(config: Record<string, string>): PluginMcpTool[];
43
+ sidebarMenuItems?(): PluginSidebarMenuItem[];
32
44
  }
33
45
  export declare const md: MarkdownIt;
34
46
  /** Strip YAML frontmatter and TipTap empty markers from markdown output */
@@ -2,6 +2,41 @@ import { getServerModules, publishFetch } from './helpers.js';
2
2
  import { newsletterTools } from './newsletter-tools.js';
3
3
  import { readFileSync, existsSync } from 'fs';
4
4
  import { join, extname } from 'path';
5
+ /** Extract docId from raw markdown frontmatter (JSON or YAML) */
6
+ function extractDocId(rawContent) {
7
+ // JSON frontmatter: "docId":"abc123"
8
+ const jsonMatch = rawContent.match(/"docId"\s*:\s*"([^"]+)"/);
9
+ if (jsonMatch)
10
+ return jsonMatch[1];
11
+ // YAML frontmatter: docId: abc123
12
+ const yamlMatch = rawContent.match(/^docId:\s*["']?(\S+?)["']?\s*$/m);
13
+ if (yamlMatch)
14
+ return yamlMatch[1];
15
+ return null;
16
+ }
17
+ /** Map transform action to variant content type */
18
+ const ACTION_VARIANT_TYPE = {
19
+ vary: 'document',
20
+ shrinkify: 'document',
21
+ expandify: 'document',
22
+ threadify: 'tweet',
23
+ storify: 'document',
24
+ emailify: 'newsletter',
25
+ postify: 'tweet',
26
+ };
27
+ /** Simple HTML → markdown conversion for document creation */
28
+ function htmlToMarkdown(html) {
29
+ let md = html;
30
+ md = md.replace(/<hr\s*\/?>/gi, '\n---\n');
31
+ md = md.replace(/<br\s*\/?>/gi, '\n');
32
+ md = md.replace(/<(strong|b)>([\s\S]*?)<\/\1>/gi, '**$2**');
33
+ md = md.replace(/<(em|i)>([\s\S]*?)<\/\1>/gi, '*$2*');
34
+ md = md.replace(/<p[^>]*>/gi, '');
35
+ md = md.replace(/<\/p>/gi, '\n\n');
36
+ md = md.replace(/<[^>]+>/g, '');
37
+ md = md.replace(/\n{3,}/g, '\n\n');
38
+ return md.trim();
39
+ }
5
40
  const plugin = {
6
41
  name: '@openwriter/plugin-publish',
7
42
  version: '0.1.0',
@@ -988,5 +1023,108 @@ const plugin = {
988
1023
  },
989
1024
  ];
990
1025
  },
1026
+ registerRoutes(ctx) {
1027
+ // Sidebar action handler for document transforms
1028
+ ctx.app.post('/api/publish/sidebar-action', async (req, res) => {
1029
+ try {
1030
+ const { action, filename, title, instructions, content } = req.body;
1031
+ console.log(`[Publish Plugin] Sidebar action: ${action} on "${title}"`);
1032
+ if (!content) {
1033
+ res.status(400).json({ error: 'Document content is required' });
1034
+ return;
1035
+ }
1036
+ // Call publish worker /transforms endpoint
1037
+ const transformRes = await publishFetch(ctx.config, '/transforms', {
1038
+ method: 'POST',
1039
+ headers: { 'Content-Type': 'application/json' },
1040
+ body: JSON.stringify({ action, content, title, instructions }),
1041
+ });
1042
+ if (!transformRes.ok) {
1043
+ const errData = await transformRes.json().catch(() => ({}));
1044
+ console.error('[Publish Plugin] Transform failed:', transformRes.status, errData);
1045
+ res.status(transformRes.status).json(errData);
1046
+ return;
1047
+ }
1048
+ const transformResult = await transformRes.json();
1049
+ // Convert HTML output to markdown for document creation
1050
+ let markdownContent = htmlToMarkdown(transformResult.html);
1051
+ // Extract source doc's docId for variant relationship
1052
+ const masterDocId = extractDocId(content);
1053
+ const variantType = ACTION_VARIANT_TYPE[action] || 'document';
1054
+ // Build document creation payload
1055
+ const createBody = {
1056
+ title: transformResult.newTitle,
1057
+ content: markdownContent,
1058
+ markPending: true,
1059
+ agentCreated: true,
1060
+ ...(masterDocId ? { masterDocId, variantType } : {}),
1061
+ };
1062
+ if (action === 'threadify') {
1063
+ // Build TipTap JSON directly — markdown parser converts "- item" lines
1064
+ // to bulletList nodes the tweet editor can't render. Using paragraph +
1065
+ // hardBreak nodes keeps all tweet text as plain text.
1066
+ if (transformResult.thread?.tweets?.length) {
1067
+ const docContent = [];
1068
+ transformResult.thread.tweets.forEach((t, i) => {
1069
+ const lines = t.text.split('\n');
1070
+ const nodes = [];
1071
+ lines.forEach((line, j) => {
1072
+ if (j > 0)
1073
+ nodes.push({ type: 'hardBreak' });
1074
+ if (line)
1075
+ nodes.push({ type: 'text', text: line });
1076
+ });
1077
+ if (nodes.length) {
1078
+ docContent.push({ type: 'paragraph', content: nodes });
1079
+ }
1080
+ if (i < transformResult.thread.tweets.length - 1) {
1081
+ docContent.push({ type: 'horizontalRule' });
1082
+ }
1083
+ });
1084
+ createBody.content = { type: 'doc', content: docContent };
1085
+ }
1086
+ createBody.metadata = { tweetContext: { mode: 'tweet' } };
1087
+ }
1088
+ // Create new document via internal HTTP call
1089
+ const host = req.get('host') || 'localhost:5050';
1090
+ const protocol = req.protocol || 'http';
1091
+ const createUrl = `${protocol}://${host}/api/documents`;
1092
+ const createRes = await fetch(createUrl, {
1093
+ method: 'POST',
1094
+ headers: { 'Content-Type': 'application/json' },
1095
+ body: JSON.stringify(createBody),
1096
+ });
1097
+ if (!createRes.ok) {
1098
+ const errData = await createRes.json().catch(() => ({}));
1099
+ console.error('[Publish Plugin] Document creation failed:', errData);
1100
+ res.status(500).json({ error: 'Failed to create result document' });
1101
+ return;
1102
+ }
1103
+ const docResult = await createRes.json();
1104
+ res.json({
1105
+ success: true,
1106
+ action,
1107
+ filename: docResult.filename,
1108
+ title: transformResult.newTitle,
1109
+ metadata: transformResult.metadata,
1110
+ });
1111
+ }
1112
+ catch (err) {
1113
+ console.error('[Publish Plugin] Sidebar action error:', err?.message || err);
1114
+ res.status(500).json({ error: 'Sidebar action failed' });
1115
+ }
1116
+ });
1117
+ },
1118
+ sidebarMenuItems() {
1119
+ return [
1120
+ { label: 'Vary', action: 'publish:vary', promptForFocus: true },
1121
+ { label: 'Shrinkify', action: 'publish:shrinkify', promptForFocus: true },
1122
+ { label: 'Expandify', action: 'publish:expandify', promptForFocus: true },
1123
+ { label: 'Threadify', action: 'publish:threadify', promptForFocus: true },
1124
+ { label: 'Storify', action: 'publish:storify', promptForFocus: true },
1125
+ { label: 'Emailify', action: 'publish:emailify', promptForFocus: true },
1126
+ { label: 'Postify', action: 'publish:postify', promptForFocus: true },
1127
+ ];
1128
+ },
991
1129
  };
992
1130
  export default plugin;
@@ -120,6 +120,8 @@ export function listDocuments() {
120
120
  ...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : {}),
121
121
  ...(data.newsletterContext ? { isNewsletter: true } : {}),
122
122
  ...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
123
+ ...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
124
+ ...(data.variantType ? { variantType: data.variantType } : {}),
123
125
  };
124
126
  }
125
127
  catch {
@@ -158,6 +160,8 @@ export function listDocuments() {
158
160
  ...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : {}),
159
161
  ...(data.newsletterContext ? { isNewsletter: true } : {}),
160
162
  ...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
163
+ ...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
164
+ ...(data.variantType ? { variantType: data.variantType } : {}),
161
165
  });
162
166
  }
163
167
  catch { /* skip unreadable external files */ }
@@ -217,6 +221,8 @@ export function listArchivedDocuments() {
217
221
  wordCount,
218
222
  isActive: false,
219
223
  ...(data.docId ? { docId: data.docId } : {}),
224
+ ...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
225
+ ...(data.variantType ? { variantType: data.variantType } : {}),
220
226
  archivedAt: data.archivedAt,
221
227
  };
222
228
  }
@@ -230,6 +230,16 @@ export async function startHttpServer(options = {}) {
230
230
  setMetadata(req.body.metadata);
231
231
  save();
232
232
  }
233
+ // Variant relationship — set masterDocId and variantType in frontmatter
234
+ if (req.body.masterDocId || req.body.variantType) {
235
+ const variantMeta = {};
236
+ if (req.body.masterDocId)
237
+ variantMeta.masterDocId = req.body.masterDocId;
238
+ if (req.body.variantType)
239
+ variantMeta.variantType = req.body.variantType;
240
+ setMetadata(variantMeta);
241
+ save();
242
+ }
233
243
  // Plugin flags: mark all content as pending + tag as agent-created
234
244
  if (req.body.markPending) {
235
245
  markAllNodesAsPending(getDocument(), 'insert');
@@ -708,13 +718,20 @@ export async function startHttpServer(options = {}) {
708
718
  }
709
719
  }
710
720
  catch { /* content stays empty */ }
711
- // Show sidebar spinner while plugin processes
721
+ // Extract source doc's docId for variant spinner positioning
722
+ let sourceDocId;
723
+ const docIdMatch = docContent.match(/"docId"\s*:\s*"([^"]+)"/);
724
+ if (docIdMatch)
725
+ sourceDocId = docIdMatch[1];
726
+ // Show sidebar spinner while plugin processes. Unique key so concurrent
727
+ // writes (e.g. declare_writes in flight) aren't cleared alongside this one.
712
728
  const spinnerTitle = label ? `${label}: ${title}` : title;
713
- broadcastWritingStarted(spinnerTitle);
729
+ const spinnerKey = `sidebar-action:${action}:${filename}:${Date.now()}`;
730
+ broadcastWritingStarted(spinnerTitle, sourceDocId ? { wsFilename: '', containerId: null, parentDocId: sourceDocId } : undefined, spinnerKey);
714
731
  // Intercept res.json to clear spinner when plugin handler responds
715
732
  const origJson = res.json.bind(res);
716
733
  res.json = (body) => {
717
- broadcastWritingFinished();
734
+ broadcastWritingFinished(spinnerKey);
718
735
  return origJson(body);
719
736
  };
720
737
  // Forward to plugin route: POST /api/{prefix}/sidebar-action
@@ -722,12 +739,13 @@ export async function startHttpServer(options = {}) {
722
739
  req.url = `/api/${prefix}/sidebar-action`;
723
740
  req.body = { action: actionName, filename, title, instructions, content: docContent };
724
741
  app.handle(req, res, () => {
725
- broadcastWritingFinished();
742
+ broadcastWritingFinished(spinnerKey);
726
743
  res.status(404).json({ error: `No handler registered for action "${action}"` });
727
744
  });
728
745
  }
729
746
  catch (err) {
730
- broadcastWritingFinished();
747
+ // spinnerKey is out of scope here (try body may have thrown before it
748
+ // was declared). The 60s timeout on the server entry cleans it up.
731
749
  res.status(500).json({ error: err.message });
732
750
  }
733
751
  });
@@ -306,11 +306,9 @@ export const TOOL_REGISTRY = [
306
306
  wsTarget = { wsFilename: ws.filename, containerId };
307
307
  broadcastWorkspacesChanged(); // Browser sees container structure before spinner
308
308
  }
309
- if (!empty) {
310
- broadcastWritingStarted(title || 'Untitled', wsTarget);
311
- // Yield so the browser receives and renders the spinner before heavy work
312
- await new Promise((resolve) => setTimeout(resolve, 200));
313
- }
309
+ // Track the spinner key so catch can clear exactly this entry
310
+ // (not siblings from a concurrent declare_writes).
311
+ let spinnerKey = null;
314
312
  try {
315
313
  if (empty) {
316
314
  // Immediate switch — no spinner, no populate_document needed
@@ -349,6 +347,11 @@ export const TOOL_REGISTRY = [
349
347
  addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
350
348
  wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
351
349
  }
350
+ // Broadcast spinner keyed by filename so populate_document can clear exactly
351
+ // this entry. Fires after the file exists, so documents-changed arrives with
352
+ // the real entry that the sidebar filters behind the spinner until populate.
353
+ spinnerKey = result.filename;
354
+ broadcastWritingStarted(title || 'Untitled', wsTarget, spinnerKey);
352
355
  broadcastDocumentsChanged();
353
356
  return {
354
357
  content: [{
@@ -358,8 +361,8 @@ export const TOOL_REGISTRY = [
358
361
  };
359
362
  }
360
363
  catch (err) {
361
- if (!empty)
362
- broadcastWritingFinished();
364
+ if (spinnerKey)
365
+ broadcastWritingFinished(spinnerKey);
363
366
  throw err;
364
367
  }
365
368
  },
@@ -387,7 +390,7 @@ export const TOOL_REGISTRY = [
387
390
  doc = content;
388
391
  }
389
392
  else {
390
- broadcastWritingFinished();
393
+ broadcastWritingFinished(filename);
391
394
  return {
392
395
  content: [{ type: 'text', text: 'Error: content must be a markdown string or TipTap JSON { type: "doc", content: [...] }' }],
393
396
  };
@@ -399,7 +402,7 @@ export const TOOL_REGISTRY = [
399
402
  broadcastDocumentsChanged();
400
403
  broadcastWorkspacesChanged();
401
404
  broadcastPendingDocsChanged();
402
- broadcastWritingFinished();
405
+ broadcastWritingFinished(filename);
403
406
  return {
404
407
  content: [{
405
408
  type: 'text',
@@ -419,7 +422,7 @@ export const TOOL_REGISTRY = [
419
422
  broadcastWorkspacesChanged();
420
423
  broadcastDocumentSwitched(doc, getTitle(), getActiveFilename());
421
424
  broadcastPendingDocsChanged();
422
- broadcastWritingFinished();
425
+ broadcastWritingFinished(filename || getActiveFilename());
423
426
  const wordCount = getWordCount();
424
427
  return {
425
428
  content: [{
@@ -429,11 +432,78 @@ export const TOOL_REGISTRY = [
429
432
  };
430
433
  }
431
434
  catch (err) {
432
- broadcastWritingFinished();
435
+ broadcastWritingFinished(filename);
433
436
  throw err;
434
437
  }
435
438
  },
436
439
  },
440
+ {
441
+ name: 'declare_writes',
442
+ description: 'Declare a batch of documents to create at once. Use this when creating multiple documents in parallel (e.g. a series of blog drafts, a tweet thread saved as separate docs, newsletter variants). Each write gets its own sidebar spinner keyed to its filename — spinners persist across app refreshes and only clear when you call populate_document for that specific doc. Returns an array of { docId, filename, title }. Next step: call populate_document once per docId (in parallel is fine). For creating a single document, prefer create_document.',
443
+ schema: {
444
+ writes: z.array(z.object({
445
+ title: z.string().describe('Title for the document.'),
446
+ content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog']).describe('Content type. Use "document" for plain docs.'),
447
+ workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it does not exist.'),
448
+ container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters"). Requires workspace.'),
449
+ url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote".'),
450
+ path: z.string().optional().describe('Absolute file path to create the document at. If omitted, creates in ~/.openwriter/.'),
451
+ })).min(1).describe('List of documents to declare (minimum 1).'),
452
+ },
453
+ handler: async ({ writes }) => {
454
+ const results = [];
455
+ let workspacesChanged = false;
456
+ const broadcastedKeys = [];
457
+ for (const w of writes) {
458
+ try {
459
+ if ((w.content_type === 'reply' || w.content_type === 'quote') && !w.url) {
460
+ results.push({ docId: '', filename: '', title: w.title, error: `content_type "${w.content_type}" requires a url parameter` });
461
+ continue;
462
+ }
463
+ let wsTarget;
464
+ if (w.workspace) {
465
+ const ws = findOrCreateWorkspace(w.workspace);
466
+ let containerId = null;
467
+ if (w.container) {
468
+ const c = findOrCreateContainer(ws.filename, w.container);
469
+ containerId = c.containerId;
470
+ }
471
+ wsTarget = { wsFilename: ws.filename, containerId };
472
+ workspacesChanged = true;
473
+ }
474
+ const typeMeta = resolveTypeMeta(w.content_type, w.url);
475
+ const result = createDocumentFile(w.title, w.path, typeMeta);
476
+ if (wsTarget) {
477
+ addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
478
+ }
479
+ broadcastWritingStarted(w.title, wsTarget, result.filename);
480
+ broadcastedKeys.push(result.filename);
481
+ results.push({ docId: result.docId, filename: result.filename, title: result.title });
482
+ }
483
+ catch (err) {
484
+ results.push({ docId: '', filename: '', title: w.title, error: err.message });
485
+ }
486
+ }
487
+ broadcastDocumentsChanged();
488
+ if (workspacesChanged)
489
+ broadcastWorkspacesChanged();
490
+ const successes = results.filter((r) => !r.error);
491
+ const failures = results.filter((r) => r.error);
492
+ const lines = [
493
+ `Declared ${successes.length} write${successes.length === 1 ? '' : 's'}${failures.length ? ` (${failures.length} failed)` : ''}:`,
494
+ ...successes.map((r) => ` "${r.title}" [${r.docId}] → ${r.filename}`),
495
+ ];
496
+ if (failures.length) {
497
+ lines.push('', 'Errors:');
498
+ for (const r of failures)
499
+ lines.push(` "${r.title}" — ${r.error}`);
500
+ }
501
+ if (successes.length) {
502
+ lines.push('', 'Next: call populate_document once per docId to fill in content.');
503
+ }
504
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
505
+ },
506
+ },
437
507
  {
438
508
  name: 'open_file',
439
509
  description: 'Open an existing .md file from any location on disk. Saves the current document first, then loads the file and sets it as active. The file appears in the sidebar and edits save back to the original path.',
package/dist/server/ws.js CHANGED
@@ -116,6 +116,11 @@ export function setupWebSocket(server) {
116
116
  type: 'pending-docs-changed',
117
117
  pendingDocs: getPendingDocInfo(),
118
118
  }));
119
+ // Rehydrate in-flight writing spinners across app refreshes
120
+ const pendingWritesSnapshot = getPendingWritesSnapshot();
121
+ if (pendingWritesSnapshot.length > 0) {
122
+ ws.send(JSON.stringify({ type: 'pending-writes-sync', writes: pendingWritesSnapshot }));
123
+ }
119
124
  ws.on('message', async (data) => {
120
125
  try {
121
126
  const msg = JSON.parse(data.toString());
@@ -377,32 +382,80 @@ export function broadcastAgentStatus(connected) {
377
382
  }
378
383
  }
379
384
  let lastSyncStatus = null;
380
- // Safety net: auto-clear spinner if writing-finished never arrives
381
- let writingTimer = null;
385
+ const pendingWrites = new Map();
382
386
  const WRITING_TIMEOUT_MS = 60_000;
383
- export function broadcastWritingStarted(title, target) {
384
- if (writingTimer)
385
- clearTimeout(writingTimer);
386
- writingTimer = setTimeout(() => {
387
- console.log('[WS] Writing spinner timed out — auto-clearing');
388
- broadcastWritingFinished();
387
+ export function broadcastWritingStarted(title, target, key) {
388
+ const writeKey = key || target?.wsFilename || `write:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
389
+ const existing = pendingWrites.get(writeKey);
390
+ if (existing)
391
+ clearTimeout(existing.timer);
392
+ const timer = setTimeout(() => {
393
+ console.log(`[WS] Writing spinner timed out for ${writeKey} — auto-clearing`);
394
+ broadcastWritingFinished(writeKey);
389
395
  }, WRITING_TIMEOUT_MS);
390
- const msg = JSON.stringify({ type: 'writing-started', title, target: target || null });
396
+ pendingWrites.set(writeKey, {
397
+ key: writeKey,
398
+ title,
399
+ target: target || null,
400
+ startedAt: Date.now(),
401
+ timer,
402
+ });
403
+ const msg = JSON.stringify({ type: 'writing-started', title, target: target || null, key: writeKey });
391
404
  for (const ws of clients) {
392
405
  if (ws.readyState === WebSocket.OPEN)
393
406
  ws.send(msg);
394
407
  }
408
+ return writeKey;
395
409
  }
396
- export function broadcastWritingFinished() {
397
- if (writingTimer) {
398
- clearTimeout(writingTimer);
399
- writingTimer = null;
410
+ // key omitted → clear all (legacy single-write flows). Pass a key for multi-doc.
411
+ export function broadcastWritingFinished(key) {
412
+ if (key) {
413
+ const entry = pendingWrites.get(key);
414
+ if (entry) {
415
+ clearTimeout(entry.timer);
416
+ pendingWrites.delete(key);
417
+ }
418
+ }
419
+ else {
420
+ for (const entry of pendingWrites.values())
421
+ clearTimeout(entry.timer);
422
+ pendingWrites.clear();
400
423
  }
401
- const msg = JSON.stringify({ type: 'writing-finished' });
424
+ // Always send writing-finished with the key so the client can drop it from
425
+ // its pending set. Then, if siblings remain, re-surface the latest with a
426
+ // writing-started so the spinner doesn't vanish mid-batch.
427
+ const finishedMsg = JSON.stringify({ type: 'writing-finished', key: key || null });
402
428
  for (const ws of clients) {
403
429
  if (ws.readyState === WebSocket.OPEN)
404
- ws.send(msg);
430
+ ws.send(finishedMsg);
405
431
  }
432
+ if (key && pendingWrites.size > 0) {
433
+ let next = null;
434
+ for (const e of pendingWrites.values()) {
435
+ if (!next || e.startedAt > next.startedAt)
436
+ next = e;
437
+ }
438
+ if (next) {
439
+ const startedMsg = JSON.stringify({
440
+ type: 'writing-started',
441
+ title: next.title,
442
+ target: next.target,
443
+ key: next.key,
444
+ });
445
+ for (const ws of clients) {
446
+ if (ws.readyState === WebSocket.OPEN)
447
+ ws.send(startedMsg);
448
+ }
449
+ }
450
+ }
451
+ }
452
+ export function getPendingWritesSnapshot() {
453
+ return Array.from(pendingWrites.values()).map(({ key, title, target, startedAt }) => ({
454
+ key,
455
+ title,
456
+ target,
457
+ startedAt,
458
+ }));
406
459
  }
407
460
  export function broadcastMarksChanged(filename) {
408
461
  const msg = JSON.stringify({ type: 'marks-changed', filename });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.9.3",
3
+ "version": "0.11.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.4.5"
19
+ version: "0.5.0"
20
20
  repository: https://github.com/travsteward/openwriter
21
21
  license: MIT
22
22
  ---
@@ -244,6 +244,38 @@ create_document({
244
244
 
245
245
  This eliminates the need for separate `create_workspace`, `create_container`, and `move_item` calls when building up a workspace.
246
246
 
247
+ ### Batched Creation (multiple docs at once)
248
+
249
+ When creating **two or more documents together** — a tweet thread saved as separate docs, a series of blog drafts, newsletter variants, a workspace populated with several files — use `declare_writes` instead of looping `create_document`. It's one tool call, registers all sidebar spinners atomically, and survives app refreshes.
250
+
251
+ ```
252
+ 1. declare_writes({
253
+ writes: [
254
+ { title: "Post 1", content_type: "tweet" },
255
+ { title: "Post 2", content_type: "tweet" },
256
+ { title: "Post 3", content_type: "tweet" },
257
+ ]
258
+ })
259
+ → returns [{ docId, filename, title }, ...]
260
+
261
+ 2. populate_document({ docId: "...", content: "..." }) ← one call per doc, parallel is fine
262
+ ```
263
+
264
+ **Rules:**
265
+ - Each write in the batch gets its own sidebar spinner keyed to its filename — a spinner only clears when you `populate_document` that specific `docId`
266
+ - Spinners persist across app refreshes (server-side registry)
267
+ - Same per-write fields as `create_document`: `title`, `content_type`, optional `workspace`/`container`/`url`/`path`
268
+ - `reply` / `quote` types still require `url`
269
+ - For a **single** document, use `create_document` — don't reach for `declare_writes` just to wrap one entry
270
+
271
+ ## Voice Frames
272
+
273
+ Pre-built voice postures for when the user wants a specific style but has no custom voice profile. Five frames cover the common needs: authority, provocateur, logical, storyteller, business.
274
+
275
+ **Triggers** — any of the following should make you load frames: "write authoritatively", "authority voice", "contrarian take", "provocateur", "first principles", "logical/analytical essay", "tell the story", "storyteller", "business email", "high-status brevity", or an explicit frame name.
276
+
277
+ **Protocol** — load `docs/voices.md` for the full selection guide and 4-step protocol. Then read the specific `voices/<frame>.md` for the rules. Apply all 6 category rules as hard constraints while drafting in the editor, and run the `docs/anti-ai.md` Tier 1 pass before leaving the output.
278
+
247
279
  ## Workflow
248
280
 
249
281
  ### Single document