openwriter 0.10.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.
Files changed (40) hide show
  1. package/dist/bin/pad.js +146 -101
  2. package/dist/client/assets/index-CNmzNvB_.js +211 -0
  3. package/dist/client/assets/index-CRImKlcp.css +1 -0
  4. package/dist/client/index.html +2 -2
  5. package/dist/server/documents.js +2 -0
  6. package/dist/server/index.js +46 -6
  7. package/dist/server/markdown-parse.js +11 -0
  8. package/dist/server/markdown-serialize.js +4 -1
  9. package/dist/server/mcp.js +89 -13
  10. package/dist/server/state.js +98 -29
  11. package/dist/server/ws.js +68 -15
  12. package/package.json +1 -1
  13. package/skill/SKILL.md +43 -1
  14. package/skill/docs/anti-ai.md +71 -0
  15. package/skill/docs/voices.md +88 -0
  16. package/skill/voices/authority.md +102 -0
  17. package/skill/voices/business.md +103 -0
  18. package/skill/voices/logical.md +104 -0
  19. package/skill/voices/provocateur.md +101 -0
  20. package/skill/voices/storyteller.md +104 -0
  21. package/dist/client/assets/index-CuPYxtxy.css +0 -1
  22. package/dist/client/assets/index-deMuWDiP.js +0 -211
  23. package/dist/plugins/authors-voice/dist/index.d.ts +0 -41
  24. package/dist/plugins/authors-voice/dist/index.js +0 -206
  25. package/dist/plugins/authors-voice/package.json +0 -23
  26. package/dist/plugins/image-gen/dist/index.d.ts +0 -35
  27. package/dist/plugins/image-gen/dist/index.js +0 -141
  28. package/dist/plugins/image-gen/package.json +0 -26
  29. package/dist/plugins/publish/dist/helpers.d.ts +0 -66
  30. package/dist/plugins/publish/dist/helpers.js +0 -199
  31. package/dist/plugins/publish/dist/index.d.ts +0 -3
  32. package/dist/plugins/publish/dist/index.js +0 -1130
  33. package/dist/plugins/publish/dist/newsletter-tools.d.ts +0 -2
  34. package/dist/plugins/publish/dist/newsletter-tools.js +0 -394
  35. package/dist/plugins/publish/package.json +0 -31
  36. package/dist/plugins/x-api/dist/index.d.ts +0 -27
  37. package/dist/plugins/x-api/dist/index.js +0 -240
  38. package/dist/plugins/x-api/package.json +0 -27
  39. package/dist/server/prompt-debug.js +0 -58
  40. package/dist/server/workspace-tags.js +0 -30
@@ -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) }] };
@@ -306,11 +309,9 @@ export const TOOL_REGISTRY = [
306
309
  wsTarget = { wsFilename: ws.filename, containerId };
307
310
  broadcastWorkspacesChanged(); // Browser sees container structure before spinner
308
311
  }
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
- }
312
+ // Track the spinner key so catch can clear exactly this entry
313
+ // (not siblings from a concurrent declare_writes).
314
+ let spinnerKey = null;
314
315
  try {
315
316
  if (empty) {
316
317
  // Immediate switch — no spinner, no populate_document needed
@@ -349,6 +350,11 @@ export const TOOL_REGISTRY = [
349
350
  addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
350
351
  wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
351
352
  }
353
+ // Broadcast spinner keyed by filename so populate_document can clear exactly
354
+ // this entry. Fires after the file exists, so documents-changed arrives with
355
+ // the real entry that the sidebar filters behind the spinner until populate.
356
+ spinnerKey = result.filename;
357
+ broadcastWritingStarted(title || 'Untitled', wsTarget, spinnerKey);
352
358
  broadcastDocumentsChanged();
353
359
  return {
354
360
  content: [{
@@ -358,8 +364,8 @@ export const TOOL_REGISTRY = [
358
364
  };
359
365
  }
360
366
  catch (err) {
361
- if (!empty)
362
- broadcastWritingFinished();
367
+ if (spinnerKey)
368
+ broadcastWritingFinished(spinnerKey);
363
369
  throw err;
364
370
  }
365
371
  },
@@ -387,7 +393,7 @@ export const TOOL_REGISTRY = [
387
393
  doc = content;
388
394
  }
389
395
  else {
390
- broadcastWritingFinished();
396
+ broadcastWritingFinished(filename);
391
397
  return {
392
398
  content: [{ type: 'text', text: 'Error: content must be a markdown string or TipTap JSON { type: "doc", content: [...] }' }],
393
399
  };
@@ -399,7 +405,7 @@ export const TOOL_REGISTRY = [
399
405
  broadcastDocumentsChanged();
400
406
  broadcastWorkspacesChanged();
401
407
  broadcastPendingDocsChanged();
402
- broadcastWritingFinished();
408
+ broadcastWritingFinished(filename);
403
409
  return {
404
410
  content: [{
405
411
  type: 'text',
@@ -407,9 +413,12 @@ export const TOOL_REGISTRY = [
407
413
  }],
408
414
  };
409
415
  }
410
- // 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.
411
418
  setAgentLock(); // Block browser doc-updates during population
412
- markAllNodesAsPending(doc, 'insert');
419
+ if (getMetadata()?.autoAccept !== true) {
420
+ markAllNodesAsPending(doc, 'insert');
421
+ }
413
422
  updateDocument(doc);
414
423
  updatePendingCacheForActiveDoc();
415
424
  save();
@@ -419,7 +428,7 @@ export const TOOL_REGISTRY = [
419
428
  broadcastWorkspacesChanged();
420
429
  broadcastDocumentSwitched(doc, getTitle(), getActiveFilename());
421
430
  broadcastPendingDocsChanged();
422
- broadcastWritingFinished();
431
+ broadcastWritingFinished(filename || getActiveFilename());
423
432
  const wordCount = getWordCount();
424
433
  return {
425
434
  content: [{
@@ -429,11 +438,78 @@ export const TOOL_REGISTRY = [
429
438
  };
430
439
  }
431
440
  catch (err) {
432
- broadcastWritingFinished();
441
+ broadcastWritingFinished(filename);
433
442
  throw err;
434
443
  }
435
444
  },
436
445
  },
446
+ {
447
+ name: 'declare_writes',
448
+ 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.',
449
+ schema: {
450
+ writes: z.array(z.object({
451
+ title: z.string().describe('Title for the document.'),
452
+ content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog']).describe('Content type. Use "document" for plain docs.'),
453
+ workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it does not exist.'),
454
+ container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters"). Requires workspace.'),
455
+ url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote".'),
456
+ path: z.string().optional().describe('Absolute file path to create the document at. If omitted, creates in ~/.openwriter/.'),
457
+ })).min(1).describe('List of documents to declare (minimum 1).'),
458
+ },
459
+ handler: async ({ writes }) => {
460
+ const results = [];
461
+ let workspacesChanged = false;
462
+ const broadcastedKeys = [];
463
+ for (const w of writes) {
464
+ try {
465
+ if ((w.content_type === 'reply' || w.content_type === 'quote') && !w.url) {
466
+ results.push({ docId: '', filename: '', title: w.title, error: `content_type "${w.content_type}" requires a url parameter` });
467
+ continue;
468
+ }
469
+ let wsTarget;
470
+ if (w.workspace) {
471
+ const ws = findOrCreateWorkspace(w.workspace);
472
+ let containerId = null;
473
+ if (w.container) {
474
+ const c = findOrCreateContainer(ws.filename, w.container);
475
+ containerId = c.containerId;
476
+ }
477
+ wsTarget = { wsFilename: ws.filename, containerId };
478
+ workspacesChanged = true;
479
+ }
480
+ const typeMeta = resolveTypeMeta(w.content_type, w.url);
481
+ const result = createDocumentFile(w.title, w.path, typeMeta);
482
+ if (wsTarget) {
483
+ addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
484
+ }
485
+ broadcastWritingStarted(w.title, wsTarget, result.filename);
486
+ broadcastedKeys.push(result.filename);
487
+ results.push({ docId: result.docId, filename: result.filename, title: result.title });
488
+ }
489
+ catch (err) {
490
+ results.push({ docId: '', filename: '', title: w.title, error: err.message });
491
+ }
492
+ }
493
+ broadcastDocumentsChanged();
494
+ if (workspacesChanged)
495
+ broadcastWorkspacesChanged();
496
+ const successes = results.filter((r) => !r.error);
497
+ const failures = results.filter((r) => r.error);
498
+ const lines = [
499
+ `Declared ${successes.length} write${successes.length === 1 ? '' : 's'}${failures.length ? ` (${failures.length} failed)` : ''}:`,
500
+ ...successes.map((r) => ` "${r.title}" [${r.docId}] → ${r.filename}`),
501
+ ];
502
+ if (failures.length) {
503
+ lines.push('', 'Errors:');
504
+ for (const r of failures)
505
+ lines.push(` "${r.title}" — ${r.error}`);
506
+ }
507
+ if (successes.length) {
508
+ lines.push('', 'Next: call populate_document once per docId to fill in content.');
509
+ }
510
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
511
+ },
512
+ },
437
513
  {
438
514
  name: 'open_file',
439
515
  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.',
@@ -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/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.10.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.4.5"
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:
@@ -244,6 +254,38 @@ create_document({
244
254
 
245
255
  This eliminates the need for separate `create_workspace`, `create_container`, and `move_item` calls when building up a workspace.
246
256
 
257
+ ### Batched Creation (multiple docs at once)
258
+
259
+ 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.
260
+
261
+ ```
262
+ 1. declare_writes({
263
+ writes: [
264
+ { title: "Post 1", content_type: "tweet" },
265
+ { title: "Post 2", content_type: "tweet" },
266
+ { title: "Post 3", content_type: "tweet" },
267
+ ]
268
+ })
269
+ → returns [{ docId, filename, title }, ...]
270
+
271
+ 2. populate_document({ docId: "...", content: "..." }) ← one call per doc, parallel is fine
272
+ ```
273
+
274
+ **Rules:**
275
+ - 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`
276
+ - Spinners persist across app refreshes (server-side registry)
277
+ - Same per-write fields as `create_document`: `title`, `content_type`, optional `workspace`/`container`/`url`/`path`
278
+ - `reply` / `quote` types still require `url`
279
+ - For a **single** document, use `create_document` — don't reach for `declare_writes` just to wrap one entry
280
+
281
+ ## Voice Frames
282
+
283
+ 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.
284
+
285
+ **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.
286
+
287
+ **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.
288
+
247
289
  ## Workflow
248
290
 
249
291
  ### Single document
@@ -0,0 +1,71 @@
1
+ # Anti-AI Detection Rules
2
+
3
+ Two tiers. Tier 1 rules are **hard rules** — fix unconditionally, no voice profile override. These patterns are so statistically associated with AI that detectors flag them regardless of context. Tier 2 rules are **voice-gated** — check against the voice profile before fixing.
4
+
5
+ ---
6
+
7
+ ## Tier 1: Hard Rules (Always Fix)
8
+
9
+ **Em-dashes — eliminate, then calibrate.** AI uses em-dashes at 5-10x human density. This is one of the strongest AI signals.
10
+ - **Default (no profile or generic voice)**: Zero em-dashes. Convert to periods, commas, or parentheses.
11
+ - **With voice profile**: Check the author's samples. If they use em-dashes, match their frequency — never exceed it. An author at 1 per 300 words gets 1 per 300 words. An author who never uses them gets zero.
12
+
13
+ **Contrastive formula — never use.** These constructions are AI fingerprints:
14
+ - "It's not X, it's Y" / "This isn't X, it's Y"
15
+ - "Rather than X, we should Y"
16
+ - "Instead of X, consider Y"
17
+ - "Not merely X, but Y"
18
+ Rewrite without the formula. State the point directly.
19
+
20
+ **Nuclear phrases — kill on sight.** These phrases are 100-900x more frequent in AI text than human text. No human writes them at this density:
21
+ - "valuable insights" (902x) · "indelible mark" (319x) · "rich tapestry" (227x)
22
+ - "crucial role in shaping" (250x) · "adds a layer of complexity" (194x)
23
+ - "a stark reminder" (151x) · "fostering a sense" (138x) · "nuanced understanding" (115x)
24
+ - "unwavering commitment" (256x) · "multifaceted nature" (92x) · "beacon of hope" (58x)
25
+ - "delve deeper" (72x) · "navigate the complex" (87x) · "transformative power" (74x)
26
+ - "shed light on" · "serves as a testament" · "underscores the importance"
27
+
28
+ **Copula avoidance — use simple verbs.** AI replaces "is" with "serves as", "has" with "boasts", "shows" with "underscores." This is a telltale pattern. Use the simple verb.
29
+
30
+ **Sycophantic filler — cut unconditionally.** "Interestingly", "It's worth noting", "Notably", "It is important to note that", "It's crucial to understand" — these are AI padding. Delete them. They add nothing.
31
+
32
+ **Contraction consistency — mix intentionally.** Uniform contraction use (100% contractions OR 100% expanded) is an AI signal. Real humans are inconsistent. Use "don't" in one sentence and "does not" three sentences later. The inconsistency IS the authenticity signal. Check the voice frame or profile for specific guidance.
33
+
34
+ **Colon density — cap usage.** AI overuses colons. Cap at 1 per 400-500 words depending on the voice frame. Business Framed and Provocateur: no colons at all. Authority: 1 per post. Long-form frames: 1 per 400-500 words.
35
+
36
+ **Register uniformity — break it.** AI maintains a single consistent register throughout. Real writing mixes formal vocabulary with casual asides, academic precision with colloquial reactions. Intentionally vary register within a piece. This is the "bidirectional sophistication" principle from empirical testing (GPTZero 100% → 3%).
37
+
38
+ ---
39
+
40
+ ## Tier 2: Voice-Gated Checks (Check Profile First)
41
+
42
+ These patterns are suspicious but may match the author's voice. Check the profile before fixing.
43
+
44
+ - **AI vocabulary**: "additionally", "furthermore", "landscape", "tapestry", "interplay", "pivotal", "delve", "paradigm", "leverage", "robust", "seamlessly" — check every word against the author's diction. If they don't use it, you can't either
45
+ - **Inflated significance**: "marking a pivotal moment", "a significant milestone" — does the author elevate this way? If not, cut it
46
+ - **Vague attribution**: "Experts argue", "Studies show" — does the author cite this way or make direct claims?
47
+ - **Formula transitions**: "Despite these challenges", "Future Outlook", "In conclusion", "Moreover", "Furthermore" — does the author use these? Check discourse rules
48
+ - **Rule of three**: Forcing ideas into triplets. Some authors do this naturally (check rhetoric rules). If not, break it
49
+ - **Elegant variation**: Cycling synonyms — "the man...the individual...the person." Use whatever the author would repeat
50
+ - **Sentence length uniformity**: AI defaults to medium-length sentences. Check your short/medium/long/very-long percentages against the author's distribution. Force variation to match
51
+ - **Too-clean structure**: AI writes perfect essay structure. Real writing has asides, interruptions, unexpected turns. Match the author's discourse patterns
52
+ - **Uniform paragraph length**: AI writes ~3-4 sentence paragraphs consistently. Match the author's paragraph rhythm from samples
53
+ - **Mid-formal default**: AI gravitates toward neutral professional register. Match the author's register exactly, even if blunt, profane, or fragmentary
54
+ - **Hedging where the author asserts**: "could potentially", "it might be argued" — if the rhetoric rules say direct claims, delete all hedging
55
+
56
+ ---
57
+
58
+ ## Final Check
59
+
60
+ Re-read the complete output:
61
+ 1. Count em-dashes. No profile: should be zero. With profile: does the count match the author's observed frequency? Convert any excess
62
+ 2. Scan for any contrastive formula. Rewrite if found
63
+ 3. Grep for nuclear phrases. Kill any survivors
64
+ 4. Check contraction consistency. Are contractions mixed inconsistently (not 100% one way)?
65
+ 5. Count colons. Within the frame's limit?
66
+ 6. Check register variation. Is the tone monotonously consistent, or does it mix naturally?
67
+ 7. Scan for copula inflation ("serves as", "boasts", "underscores"). Simplify to plain verbs
68
+ 8. Would a reader who knows this author believe they wrote this?
69
+ 9. Does any sentence sound like "AI writing" rather than this specific person?
70
+
71
+ If anything fails, rewrite that section. Don't patch — rewrite using the samples as reference.