openwriter 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/pad.js +146 -101
- package/dist/client/assets/{index-DCMxNd__.js → index-CNmzNvB_.js} +50 -50
- package/dist/client/assets/{index-Cc-WcvZz.css → index-CRImKlcp.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/documents.js +2 -0
- package/dist/server/index.js +38 -1
- package/dist/server/markdown-parse.js +11 -0
- package/dist/server/markdown-serialize.js +4 -1
- package/dist/server/mcp.js +8 -2
- package/dist/server/state.js +98 -29
- package/package.json +1 -1
- package/skill/SKILL.md +11 -1
- 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/client/index.html
CHANGED
|
@@ -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-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-CNmzNvB_.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CRImKlcp.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
package/dist/server/documents.js
CHANGED
|
@@ -122,6 +122,7 @@ export function listDocuments() {
|
|
|
122
122
|
...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
|
|
123
123
|
...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
|
|
124
124
|
...(data.variantType ? { variantType: data.variantType } : {}),
|
|
125
|
+
...(data.autoAccept === true ? { autoAccept: true } : {}),
|
|
125
126
|
};
|
|
126
127
|
}
|
|
127
128
|
catch {
|
|
@@ -162,6 +163,7 @@ export function listDocuments() {
|
|
|
162
163
|
...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
|
|
163
164
|
...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
|
|
164
165
|
...(data.variantType ? { variantType: data.variantType } : {}),
|
|
166
|
+
...(data.autoAccept === true ? { autoAccept: true } : {}),
|
|
165
167
|
});
|
|
166
168
|
}
|
|
167
169
|
catch { /* skip unreadable external files */ }
|
package/dist/server/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadc
|
|
|
11
11
|
import { TOOL_REGISTRY } from './mcp.js';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
14
|
-
import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, removePendingCacheEntry, clearAllCaches } from './state.js';
|
|
14
|
+
import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, removePendingCacheEntry, clearAllCaches, stripPendingAttrs, stripPendingAttrsFromFile, setAutoAcceptOnFile } from './state.js';
|
|
15
15
|
import { syncPostHistory } from './post-sync.js';
|
|
16
16
|
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename, batchResolve } from './documents.js';
|
|
17
17
|
import { createWorkspaceRouter } from './workspace-routes.js';
|
|
@@ -154,6 +154,43 @@ export async function startHttpServer(options = {}) {
|
|
|
154
154
|
res.status(500).json({ error: err.message });
|
|
155
155
|
}
|
|
156
156
|
});
|
|
157
|
+
// Toggle auto-accept on a document. Body: { filename, enabled }.
|
|
158
|
+
// When enabling, any currently-pending changes are accepted in place so the
|
|
159
|
+
// user enters a clean state — agent writes from this point commit directly.
|
|
160
|
+
app.post('/api/auto-accept', (req, res) => {
|
|
161
|
+
try {
|
|
162
|
+
const filename = req.body?.filename;
|
|
163
|
+
const enabled = req.body?.enabled === true;
|
|
164
|
+
if (!filename)
|
|
165
|
+
return res.status(400).json({ error: 'filename required' });
|
|
166
|
+
const isActiveDoc = filename === getActiveFilename();
|
|
167
|
+
if (isActiveDoc) {
|
|
168
|
+
if (enabled) {
|
|
169
|
+
stripPendingAttrs(); // accept any pending changes
|
|
170
|
+
setMetadata({ autoAccept: true });
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const meta = getMetadata();
|
|
174
|
+
delete meta.autoAccept;
|
|
175
|
+
}
|
|
176
|
+
save();
|
|
177
|
+
updatePendingCacheForActiveDoc();
|
|
178
|
+
broadcastMetadataChanged(getMetadata());
|
|
179
|
+
broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename(), getMetadata());
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
if (enabled)
|
|
183
|
+
stripPendingAttrsFromFile(filename, true);
|
|
184
|
+
setAutoAcceptOnFile(filename, enabled);
|
|
185
|
+
}
|
|
186
|
+
broadcastDocumentsChanged();
|
|
187
|
+
broadcastPendingDocsChanged();
|
|
188
|
+
res.json({ success: true });
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
res.status(500).json({ error: err.message });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
157
194
|
app.post('/api/save', (_req, res) => {
|
|
158
195
|
save();
|
|
159
196
|
res.json({ success: true });
|
|
@@ -400,6 +400,17 @@ function inlineTokensToTiptap(tokens) {
|
|
|
400
400
|
if (/^<br\s*\/?>$/i.test(token.content.trim())) {
|
|
401
401
|
nodes.push({ type: 'hardBreak' });
|
|
402
402
|
}
|
|
403
|
+
else {
|
|
404
|
+
// Preserve raw HTML-looking text so user content isn't silently dropped.
|
|
405
|
+
// On serialize, escapeInlineHtml converts `<` to `<`; on the next
|
|
406
|
+
// parse markdown-it decodes `<` back to a single text token, so the
|
|
407
|
+
// round-trip is stable after the first save.
|
|
408
|
+
const textNode = { type: 'text', text: token.content };
|
|
409
|
+
if (markStack.length > 0) {
|
|
410
|
+
textNode.marks = deduplicateMarks(markStack);
|
|
411
|
+
}
|
|
412
|
+
nodes.push(textNode);
|
|
413
|
+
}
|
|
403
414
|
}
|
|
404
415
|
else if (token.type === 'hardbreak') {
|
|
405
416
|
nodes.push({ type: 'hardBreak' });
|
|
@@ -237,7 +237,10 @@ export function inlineToMarkdown(nodes) {
|
|
|
237
237
|
for (let i = commonLen; i < targetMarks.length; i++) {
|
|
238
238
|
result += markSyntax(targetMarks[i], true);
|
|
239
239
|
}
|
|
240
|
-
|
|
240
|
+
// Skip HTML escape for text inside inline code — CommonMark treats
|
|
241
|
+
// backtick spans as verbatim, so `<` would render literally.
|
|
242
|
+
const hasCodeMark = (node.marks || []).some((m) => m.type === 'code');
|
|
243
|
+
result += hasCodeMark ? (node.text || '') : escapeInlineHtml(node.text || '');
|
|
241
244
|
openMarks = [...targetMarks];
|
|
242
245
|
}
|
|
243
246
|
// Close remaining marks
|
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) }] };
|
|
@@ -410,9 +413,12 @@ export const TOOL_REGISTRY = [
|
|
|
410
413
|
}],
|
|
411
414
|
};
|
|
412
415
|
}
|
|
413
|
-
// Active target (or no filename): existing flow
|
|
416
|
+
// Active target (or no filename): existing flow.
|
|
417
|
+
// Skip pending tagging when autoAccept is on for this doc — content commits directly.
|
|
414
418
|
setAgentLock(); // Block browser doc-updates during population
|
|
415
|
-
|
|
419
|
+
if (getMetadata()?.autoAccept !== true) {
|
|
420
|
+
markAllNodesAsPending(doc, 'insert');
|
|
421
|
+
}
|
|
416
422
|
updateDocument(doc);
|
|
417
423
|
updatePendingCacheForActiveDoc();
|
|
418
424
|
save();
|
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/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:
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Author's Voice plugin for OpenWriter.
|
|
3
|
-
* Proxies /api/voice/* to the AV backend and adds context menu items
|
|
4
|
-
* for rewriting, shrinking, expanding, and custom instructions.
|
|
5
|
-
* Also registers sidebar menu items for document-level transforms.
|
|
6
|
-
*/
|
|
7
|
-
import type { Express } from 'express';
|
|
8
|
-
interface PluginConfigField {
|
|
9
|
-
type: 'string' | 'number' | 'boolean';
|
|
10
|
-
required?: boolean;
|
|
11
|
-
env?: string;
|
|
12
|
-
description?: string;
|
|
13
|
-
}
|
|
14
|
-
interface PluginRouteContext {
|
|
15
|
-
app: Express;
|
|
16
|
-
config: Record<string, string>;
|
|
17
|
-
}
|
|
18
|
-
interface PluginContextMenuItem {
|
|
19
|
-
label: string;
|
|
20
|
-
shortcut?: string;
|
|
21
|
-
action: string;
|
|
22
|
-
condition?: 'has-selection' | 'empty-node' | 'always';
|
|
23
|
-
promptForInput?: boolean;
|
|
24
|
-
}
|
|
25
|
-
interface PluginSidebarMenuItem {
|
|
26
|
-
label: string;
|
|
27
|
-
action: string;
|
|
28
|
-
promptForFocus?: boolean;
|
|
29
|
-
}
|
|
30
|
-
interface OpenWriterPlugin {
|
|
31
|
-
name: string;
|
|
32
|
-
version: string;
|
|
33
|
-
description?: string;
|
|
34
|
-
category?: 'writing' | 'social-media' | 'image-generation';
|
|
35
|
-
configSchema?: Record<string, PluginConfigField>;
|
|
36
|
-
registerRoutes?(ctx: PluginRouteContext): void | Promise<void>;
|
|
37
|
-
contextMenuItems?(): PluginContextMenuItem[];
|
|
38
|
-
sidebarMenuItems?(): PluginSidebarMenuItem[];
|
|
39
|
-
}
|
|
40
|
-
declare const plugin: OpenWriterPlugin;
|
|
41
|
-
export default plugin;
|
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Author's Voice plugin for OpenWriter.
|
|
3
|
-
* Proxies /api/voice/* to the AV backend and adds context menu items
|
|
4
|
-
* for rewriting, shrinking, expanding, and custom instructions.
|
|
5
|
-
* Also registers sidebar menu items for document-level transforms.
|
|
6
|
-
*/
|
|
7
|
-
/** Simple HTML → markdown conversion for document creation */
|
|
8
|
-
function htmlToMarkdown(html) {
|
|
9
|
-
let md = html;
|
|
10
|
-
// <hr> → horizontal rule
|
|
11
|
-
md = md.replace(/<hr\s*\/?>/gi, '\n---\n');
|
|
12
|
-
// <br> → newline
|
|
13
|
-
md = md.replace(/<br\s*\/?>/gi, '\n');
|
|
14
|
-
// <strong>/<b> → **bold**
|
|
15
|
-
md = md.replace(/<(strong|b)>([\s\S]*?)<\/\1>/gi, '**$2**');
|
|
16
|
-
// <em>/<i> → *italic*
|
|
17
|
-
md = md.replace(/<(em|i)>([\s\S]*?)<\/\1>/gi, '*$2*');
|
|
18
|
-
// <p> → paragraph boundaries
|
|
19
|
-
md = md.replace(/<p[^>]*>/gi, '');
|
|
20
|
-
md = md.replace(/<\/p>/gi, '\n\n');
|
|
21
|
-
// Strip remaining tags
|
|
22
|
-
md = md.replace(/<[^>]+>/g, '');
|
|
23
|
-
// Normalize whitespace
|
|
24
|
-
md = md.replace(/\n{3,}/g, '\n\n');
|
|
25
|
-
return md.trim();
|
|
26
|
-
}
|
|
27
|
-
const plugin = {
|
|
28
|
-
name: '@openwriter/plugin-authors-voice',
|
|
29
|
-
version: '0.1.0',
|
|
30
|
-
description: "Rewrite text in your voice using Author's Voice",
|
|
31
|
-
category: 'writing',
|
|
32
|
-
configSchema: {
|
|
33
|
-
'api-key': {
|
|
34
|
-
type: 'string',
|
|
35
|
-
required: true,
|
|
36
|
-
env: 'AV_API_KEY',
|
|
37
|
-
description: 'Author\'s Voice API key',
|
|
38
|
-
},
|
|
39
|
-
'backend-url': {
|
|
40
|
-
type: 'string',
|
|
41
|
-
env: 'AV_BACKEND_URL',
|
|
42
|
-
description: 'AV backend URL',
|
|
43
|
-
},
|
|
44
|
-
},
|
|
45
|
-
registerRoutes(ctx) {
|
|
46
|
-
const backendUrl = ctx.config['backend-url'] || process.env.AV_BACKEND_URL || 'https://authors-voice.com';
|
|
47
|
-
const apiKey = ctx.config['api-key'] || process.env.AV_API_KEY || '';
|
|
48
|
-
const authHeaders = () => {
|
|
49
|
-
const h = { 'Content-Type': 'application/json' };
|
|
50
|
-
if (apiKey)
|
|
51
|
-
h['Authorization'] = `Bearer ${apiKey}`;
|
|
52
|
-
return h;
|
|
53
|
-
};
|
|
54
|
-
// Sidebar action handler — must be registered BEFORE the wildcard
|
|
55
|
-
ctx.app.post('/api/voice/sidebar-action', async (req, res) => {
|
|
56
|
-
try {
|
|
57
|
-
const { action, filename, title, instructions, content } = req.body;
|
|
58
|
-
console.log(`[AV Plugin] Sidebar action: ${action} on "${title}"`);
|
|
59
|
-
if (!content) {
|
|
60
|
-
res.status(400).json({ error: 'Document content is required' });
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
// Call AV backend transform endpoint
|
|
64
|
-
const transformUrl = `${backendUrl}/api/voice/transform`;
|
|
65
|
-
const upstream = await fetch(transformUrl, {
|
|
66
|
-
method: 'POST',
|
|
67
|
-
headers: authHeaders(),
|
|
68
|
-
body: JSON.stringify({ action, content, title, instructions }),
|
|
69
|
-
});
|
|
70
|
-
if (!upstream.ok) {
|
|
71
|
-
const errData = await upstream.json().catch(() => ({}));
|
|
72
|
-
console.error('[AV Plugin] Transform failed:', upstream.status, errData);
|
|
73
|
-
res.status(upstream.status).json(errData);
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
const transformResult = await upstream.json();
|
|
77
|
-
// Convert HTML output to markdown for document creation
|
|
78
|
-
let markdownContent = htmlToMarkdown(transformResult.html);
|
|
79
|
-
// Threadify: always create as tweet template
|
|
80
|
-
const createBody = {
|
|
81
|
-
title: transformResult.newTitle,
|
|
82
|
-
content: markdownContent,
|
|
83
|
-
markPending: true,
|
|
84
|
-
agentCreated: true,
|
|
85
|
-
};
|
|
86
|
-
if (action === 'threadify') {
|
|
87
|
-
// Build TipTap JSON directly to avoid markdown parsing issues.
|
|
88
|
-
// Markdown parser converts "- item" lines to bulletList nodes that the
|
|
89
|
-
// tweet editor can't render (bulletList extension is disabled), causing
|
|
90
|
-
// empty gaps. By building JSON with only paragraph + hardBreak nodes,
|
|
91
|
-
// all tweet text stays as plain text.
|
|
92
|
-
if (transformResult.thread?.tweets?.length) {
|
|
93
|
-
const docContent = [];
|
|
94
|
-
transformResult.thread.tweets.forEach((t, i) => {
|
|
95
|
-
// Single paragraph per tweet. Split on \n only:
|
|
96
|
-
// \n → one hardBreak (tight line), \n\n → two hardBreaks (blank line spacing)
|
|
97
|
-
const lines = t.text.split('\n');
|
|
98
|
-
const nodes = [];
|
|
99
|
-
lines.forEach((line, j) => {
|
|
100
|
-
if (j > 0)
|
|
101
|
-
nodes.push({ type: 'hardBreak' });
|
|
102
|
-
if (line)
|
|
103
|
-
nodes.push({ type: 'text', text: line });
|
|
104
|
-
});
|
|
105
|
-
if (nodes.length) {
|
|
106
|
-
docContent.push({ type: 'paragraph', content: nodes });
|
|
107
|
-
}
|
|
108
|
-
if (i < transformResult.thread.tweets.length - 1) {
|
|
109
|
-
docContent.push({ type: 'horizontalRule' });
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
createBody.content = { type: 'doc', content: docContent };
|
|
113
|
-
}
|
|
114
|
-
createBody.metadata = { tweetContext: { mode: 'tweet' } };
|
|
115
|
-
}
|
|
116
|
-
// Create new document in OpenWriter via internal HTTP call
|
|
117
|
-
const host = req.get('host') || 'localhost:5050';
|
|
118
|
-
const protocol = req.protocol || 'http';
|
|
119
|
-
const createUrl = `${protocol}://${host}/api/documents`;
|
|
120
|
-
const createRes = await fetch(createUrl, {
|
|
121
|
-
method: 'POST',
|
|
122
|
-
headers: { 'Content-Type': 'application/json' },
|
|
123
|
-
body: JSON.stringify(createBody),
|
|
124
|
-
});
|
|
125
|
-
if (!createRes.ok) {
|
|
126
|
-
const errData = await createRes.json().catch(() => ({}));
|
|
127
|
-
console.error('[AV Plugin] Document creation failed:', errData);
|
|
128
|
-
res.status(500).json({ error: 'Failed to create result document' });
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
const docResult = await createRes.json();
|
|
132
|
-
res.json({
|
|
133
|
-
success: true,
|
|
134
|
-
action,
|
|
135
|
-
filename: docResult.filename,
|
|
136
|
-
title: transformResult.newTitle,
|
|
137
|
-
metadata: transformResult.metadata,
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
catch (err) {
|
|
141
|
-
console.error('[AV Plugin] Sidebar action error:', err?.message || err);
|
|
142
|
-
res.status(500).json({ error: 'Sidebar action failed' });
|
|
143
|
-
}
|
|
144
|
-
});
|
|
145
|
-
// Wildcard proxy for all other /api/voice/* routes
|
|
146
|
-
ctx.app.post('/api/voice/*', async (req, res) => {
|
|
147
|
-
try {
|
|
148
|
-
const subPath = req.params[0] || '';
|
|
149
|
-
const targetUrl = `${backendUrl}/api/voice/${subPath}`;
|
|
150
|
-
console.log(`[AV Plugin] ${req.method} ${req.path} → ${targetUrl}`);
|
|
151
|
-
const upstream = await fetch(targetUrl, {
|
|
152
|
-
method: 'POST',
|
|
153
|
-
headers: authHeaders(),
|
|
154
|
-
body: JSON.stringify(req.body),
|
|
155
|
-
});
|
|
156
|
-
res.status(upstream.status);
|
|
157
|
-
const forwardHeaders = ['x-usage-rewrite-count', 'x-usage-rewrite-limit', 'x-usage-resets-at'];
|
|
158
|
-
for (const h of forwardHeaders) {
|
|
159
|
-
const val = upstream.headers.get(h);
|
|
160
|
-
if (val)
|
|
161
|
-
res.setHeader(h, val);
|
|
162
|
-
}
|
|
163
|
-
const responseText = await upstream.text();
|
|
164
|
-
try {
|
|
165
|
-
const data = JSON.parse(responseText);
|
|
166
|
-
res.json(data);
|
|
167
|
-
}
|
|
168
|
-
catch {
|
|
169
|
-
console.error('[AV Plugin] Non-JSON response:', responseText.substring(0, 500));
|
|
170
|
-
res.status(502).json({ error: 'AV backend returned non-JSON response' });
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
catch (err) {
|
|
174
|
-
console.error('[AV Plugin] Backend error:', err?.message || err);
|
|
175
|
-
res.status(502).json({ error: 'AV backend unreachable' });
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
|
-
},
|
|
179
|
-
contextMenuItems() {
|
|
180
|
-
return [
|
|
181
|
-
// Selection actions (require highlighted text)
|
|
182
|
-
{ label: 'Enhance', shortcut: 'R', action: 'av:rewrite', condition: 'has-selection' },
|
|
183
|
-
{ label: 'Modify...', action: 'av:custom', condition: 'has-selection', promptForInput: true },
|
|
184
|
-
{ label: 'Shrink', shortcut: 'S', action: 'av:shrink', condition: 'has-selection' },
|
|
185
|
-
{ label: 'Expand', shortcut: 'E', action: 'av:expand', condition: 'has-selection' },
|
|
186
|
-
// Empty node actions (cursor on empty line)
|
|
187
|
-
{ label: 'Insert', shortcut: 'I', action: 'av:insert', condition: 'empty-node', promptForInput: true },
|
|
188
|
-
{ label: 'Fill paragraph', shortcut: 'F', action: 'av:fill', condition: 'empty-node' },
|
|
189
|
-
{ label: 'Fill sentence', action: 'av:fill-sentence', condition: 'empty-node' },
|
|
190
|
-
];
|
|
191
|
-
},
|
|
192
|
-
// Sidebar transforms disabled — now handled by publish plugin.
|
|
193
|
-
// Kept commented for reference during transition.
|
|
194
|
-
// sidebarMenuItems() {
|
|
195
|
-
// return [
|
|
196
|
-
// { label: 'Vary', action: 'voice:vary', promptForFocus: true },
|
|
197
|
-
// { label: 'Shrinkify', action: 'voice:shrinkify', promptForFocus: true },
|
|
198
|
-
// { label: 'Expandify', action: 'voice:expandify', promptForFocus: true },
|
|
199
|
-
// { label: 'Threadify', action: 'voice:threadify', promptForFocus: true },
|
|
200
|
-
// { label: 'Storify', action: 'voice:storify', promptForFocus: true },
|
|
201
|
-
// { label: 'Emailify', action: 'voice:emailify', promptForFocus: true },
|
|
202
|
-
// { label: 'Postify', action: 'voice:postify', promptForFocus: true },
|
|
203
|
-
// ];
|
|
204
|
-
// },
|
|
205
|
-
};
|
|
206
|
-
export default plugin;
|