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.
- package/dist/bin/pad.js +146 -101
- package/dist/client/assets/index-CNmzNvB_.js +211 -0
- package/dist/client/assets/index-CRImKlcp.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/documents.js +2 -0
- package/dist/server/index.js +46 -6
- package/dist/server/markdown-parse.js +11 -0
- package/dist/server/markdown-serialize.js +4 -1
- package/dist/server/mcp.js +89 -13
- package/dist/server/state.js +98 -29
- package/dist/server/ws.js +68 -15
- package/package.json +1 -1
- package/skill/SKILL.md +43 -1
- package/skill/docs/anti-ai.md +71 -0
- package/skill/docs/voices.md +88 -0
- package/skill/voices/authority.md +102 -0
- package/skill/voices/business.md +103 -0
- package/skill/voices/logical.md +104 -0
- package/skill/voices/provocateur.md +101 -0
- package/skill/voices/storyteller.md +104 -0
- package/dist/client/assets/index-CuPYxtxy.css +0 -1
- package/dist/client/assets/index-deMuWDiP.js +0 -211
- package/dist/plugins/authors-voice/dist/index.d.ts +0 -41
- package/dist/plugins/authors-voice/dist/index.js +0 -206
- package/dist/plugins/authors-voice/package.json +0 -23
- package/dist/plugins/image-gen/dist/index.d.ts +0 -35
- package/dist/plugins/image-gen/dist/index.js +0 -141
- package/dist/plugins/image-gen/package.json +0 -26
- package/dist/plugins/publish/dist/helpers.d.ts +0 -66
- package/dist/plugins/publish/dist/helpers.js +0 -199
- package/dist/plugins/publish/dist/index.d.ts +0 -3
- package/dist/plugins/publish/dist/index.js +0 -1130
- package/dist/plugins/publish/dist/newsletter-tools.d.ts +0 -2
- package/dist/plugins/publish/dist/newsletter-tools.js +0 -394
- package/dist/plugins/publish/package.json +0 -31
- package/dist/plugins/x-api/dist/index.d.ts +0 -27
- package/dist/plugins/x-api/dist/index.js +0 -240
- package/dist/plugins/x-api/package.json +0 -27
- package/dist/server/prompt-debug.js +0 -58
- package/dist/server/workspace-tags.js +0 -30
package/dist/server/mcp.js
CHANGED
|
@@ -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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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 (
|
|
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
|
-
|
|
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.',
|
package/dist/server/state.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
632
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
|
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
|
-
//
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
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
|
-
|
|
381
|
-
let writingTimer = null;
|
|
385
|
+
const pendingWrites = new Map();
|
|
382
386
|
const WRITING_TIMEOUT_MS = 60_000;
|
|
383
|
-
export function broadcastWritingStarted(title, target) {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|