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