openwriter 0.40.1 → 0.40.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/plugins/authors-voice/dist/index.d.ts +48 -0
  2. package/dist/plugins/authors-voice/dist/index.js +235 -0
  3. package/dist/plugins/authors-voice/package.json +24 -0
  4. package/dist/plugins/authors-voice/skill/LICENSE +21 -0
  5. package/dist/plugins/authors-voice/skill/README.md +126 -0
  6. package/dist/plugins/authors-voice/skill/SKILL.md +151 -0
  7. package/dist/plugins/authors-voice/skill/catalog/ai-tells.md +144 -0
  8. package/dist/plugins/authors-voice/skill/catalog/anchor-prompt.md +189 -0
  9. package/dist/plugins/authors-voice/skill/catalog/author-hints.md +119 -0
  10. package/dist/plugins/authors-voice/skill/catalog/fingerprints.md +175 -0
  11. package/dist/plugins/authors-voice/skill/catalog/hurdle.md +76 -0
  12. package/dist/plugins/authors-voice/skill/catalog/post-write-audit.md +105 -0
  13. package/dist/plugins/authors-voice/skill/docs/analysis.md +31 -0
  14. package/dist/plugins/authors-voice/skill/docs/anchor-iteration.md +176 -0
  15. package/dist/plugins/authors-voice/skill/docs/api/import.md +78 -0
  16. package/dist/plugins/authors-voice/skill/docs/api/protocol.md +140 -0
  17. package/dist/plugins/authors-voice/skill/docs/api/setup.md +37 -0
  18. package/dist/plugins/authors-voice/skill/docs/api/tools.md +102 -0
  19. package/dist/plugins/authors-voice/skill/docs/api/troubleshooting.md +7 -0
  20. package/dist/plugins/authors-voice/skill/docs/apply-protocol-deep.md +191 -0
  21. package/dist/plugins/authors-voice/skill/docs/context-hygiene.md +33 -0
  22. package/dist/plugins/authors-voice/skill/docs/setup.md +74 -0
  23. package/dist/plugins/authors-voice/skill/docs/tiers.md +13 -0
  24. package/dist/plugins/authors-voice/skill/package.json +35 -0
  25. package/dist/plugins/authors-voice/skill/prompts/skeleton.md +29 -0
  26. package/dist/plugins/authors-voice/skill/voice/README.md +51 -0
  27. package/dist/plugins/authors-voice/skill/voice/corpus/.gitkeep +0 -0
  28. package/dist/plugins/github/dist/blog-tools.d.ts +84 -0
  29. package/dist/plugins/github/dist/blog-tools.js +1208 -0
  30. package/dist/plugins/github/dist/git-sync.d.ts +46 -0
  31. package/dist/plugins/github/dist/git-sync.js +335 -0
  32. package/dist/plugins/github/dist/helpers.d.ts +127 -0
  33. package/dist/plugins/github/dist/helpers.js +67 -0
  34. package/dist/plugins/github/dist/index.d.ts +12 -0
  35. package/dist/plugins/github/dist/index.js +112 -0
  36. package/dist/plugins/github/package.json +24 -0
  37. package/dist/plugins/image-gen/dist/index.d.ts +35 -0
  38. package/dist/plugins/image-gen/dist/index.js +149 -0
  39. package/dist/plugins/image-gen/package.json +26 -0
  40. package/dist/plugins/publish/dist/helpers.d.ts +66 -0
  41. package/dist/plugins/publish/dist/helpers.js +199 -0
  42. package/dist/plugins/publish/dist/index.d.ts +3 -0
  43. package/dist/plugins/publish/dist/index.js +1156 -0
  44. package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
  45. package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
  46. package/dist/plugins/publish/package.json +31 -0
  47. package/dist/plugins/x-api/dist/index.d.ts +27 -0
  48. package/dist/plugins/x-api/dist/index.js +368 -0
  49. package/dist/plugins/x-api/dist/server-bridge.d.ts +22 -0
  50. package/dist/plugins/x-api/dist/server-bridge.js +43 -0
  51. package/dist/plugins/x-api/package.json +27 -0
  52. package/package.json +1 -1
  53. package/skill/docs/enrichment.md +180 -0
  54. package/skill/docs/footnotes.md +178 -0
  55. package/skill/docs/setup.md +62 -0
  56. package/skill/docs/welcome.md +21 -0
@@ -0,0 +1,368 @@
1
+ /**
2
+ * X API plugin for OpenWriter.
3
+ * Registers routes for checking X connection status and posting tweets.
4
+ * Uses @xdevplatform/xdk with OAuth1 credentials from plugin config.
5
+ */
6
+ import { Client, OAuth1 } from '@xdevplatform/xdk';
7
+ import { join, extname } from 'path';
8
+ import { readFileSync, existsSync } from 'fs';
9
+ import sharp from 'sharp';
10
+ import twitter from 'twitter-text';
11
+ import { getServerBridge } from './server-bridge.js';
12
+ const { parseTweet } = twitter;
13
+ /** X Articles API base. Two-step: draft, then publish. */
14
+ const X_API_BASE = 'https://api.x.com/2';
15
+ /** Build the OAuth1 signer from plugin config / env. Returns null when any of
16
+ * the four credentials is missing. Shared by the SDK client and the raw
17
+ * Articles calls (which the SDK doesn't model). */
18
+ function createOAuth1(config) {
19
+ const apiKey = config['api-key'] || process.env.X_API_KEY || '';
20
+ const apiSecret = config['api-secret'] || process.env.X_API_SECRET || '';
21
+ const accessToken = config['access-token'] || process.env.X_ACCESS_TOKEN || '';
22
+ const accessTokenSecret = config['access-token-secret'] || process.env.X_ACCESS_TOKEN_SECRET || '';
23
+ if (!apiKey || !apiSecret || !accessToken || !accessTokenSecret)
24
+ return null;
25
+ return new OAuth1({
26
+ apiKey,
27
+ apiSecret,
28
+ callback: 'oob',
29
+ accessToken,
30
+ accessTokenSecret,
31
+ });
32
+ }
33
+ function createXClient(config) {
34
+ const oauth1 = createOAuth1(config);
35
+ if (!oauth1)
36
+ return null;
37
+ return new Client({ oauth1 });
38
+ }
39
+ /** Make an OAuth1-signed JSON request to the X API. The SDK has no Articles
40
+ * resource, so we sign + fetch directly. `buildRequestHeader` correctly
41
+ * excludes a JSON body from the OAuth1 signature base string. */
42
+ async function xApiFetch(oauth1, method, path, body) {
43
+ const url = `${X_API_BASE}${path}`;
44
+ const bodyStr = body ? JSON.stringify(body) : '';
45
+ const authHeader = await oauth1.buildRequestHeader(method, url, bodyStr);
46
+ const headers = { Authorization: authHeader };
47
+ if (body)
48
+ headers['Content-Type'] = 'application/json';
49
+ const res = await fetch(url, { method, headers, body: body ? bodyStr : undefined });
50
+ const data = await res.json().catch(() => ({}));
51
+ return { ok: res.ok, status: res.status, data };
52
+ }
53
+ const plugin = {
54
+ name: '@openwriter/plugin-x-api',
55
+ version: '0.1.0',
56
+ description: 'Post tweets from OpenWriter',
57
+ category: 'social-media',
58
+ configSchema: {
59
+ 'api-key': { type: 'string', env: 'X_API_KEY', description: 'X API Key' },
60
+ 'api-secret': { type: 'string', env: 'X_API_SECRET', description: 'X API Secret' },
61
+ 'access-token': { type: 'string', env: 'X_ACCESS_TOKEN', description: 'X Access Token' },
62
+ 'access-token-secret': { type: 'string', env: 'X_ACCESS_TOKEN_SECRET', description: 'X Access Token Secret' },
63
+ },
64
+ registerRoutes(ctx) {
65
+ // GET /api/x/status — check if plugin is configured + authenticated
66
+ ctx.app.get('/api/x/status', async (_req, res) => {
67
+ try {
68
+ const client = createXClient(ctx.config);
69
+ if (!client) {
70
+ res.json({ connected: false });
71
+ return;
72
+ }
73
+ const me = await client.users.getMe();
74
+ const username = me?.data?.username;
75
+ res.json({ connected: true, username: username || undefined });
76
+ }
77
+ catch (err) {
78
+ console.error('[X Plugin] Status check failed:', err.message);
79
+ res.json({ connected: false, error: err.message });
80
+ }
81
+ });
82
+ // POST /api/x/post — post a tweet (with optional media)
83
+ ctx.app.post('/api/x/post', async (req, res) => {
84
+ try {
85
+ const { text, replyTo, quoteTweetId, mediaIds } = req.body;
86
+ if ((!text || typeof text !== 'string') && (!Array.isArray(mediaIds) || mediaIds.length === 0)) {
87
+ res.status(400).json({ success: false, error: 'text or mediaIds is required' });
88
+ return;
89
+ }
90
+ if (mediaIds && (!Array.isArray(mediaIds) || mediaIds.length > 4)) {
91
+ res.status(400).json({ success: false, error: 'mediaIds must be an array of 1-4 IDs' });
92
+ return;
93
+ }
94
+ const client = createXClient(ctx.config);
95
+ if (!client) {
96
+ res.status(400).json({ success: false, error: 'X API credentials not configured' });
97
+ return;
98
+ }
99
+ const body = {};
100
+ if (text)
101
+ body.text = text;
102
+ if (replyTo) {
103
+ body.reply = { inReplyToTweetId: replyTo };
104
+ }
105
+ if (quoteTweetId) {
106
+ body.quoteTweetId = quoteTweetId;
107
+ }
108
+ if (mediaIds && mediaIds.length > 0) {
109
+ body.media = { media_ids: mediaIds };
110
+ }
111
+ const result = await client.posts.create(body);
112
+ const tweetId = result?.data?.id;
113
+ const tweetUrl = tweetId ? `https://x.com/i/status/${tweetId}` : undefined;
114
+ res.json({ success: true, tweetId, tweetUrl });
115
+ }
116
+ catch (err) {
117
+ const detail = err.data ? JSON.stringify(err.data) : err.message;
118
+ console.error('[X Plugin] Post failed:', detail);
119
+ res.status(500).json({ success: false, error: detail });
120
+ }
121
+ });
122
+ // POST /api/x/post-thread — post a full thread as a reply chain (with optional media per tweet)
123
+ ctx.app.post('/api/x/post-thread', async (req, res) => {
124
+ try {
125
+ const { tweets, replyTo } = req.body;
126
+ if (!Array.isArray(tweets) || tweets.length === 0) {
127
+ res.status(400).json({ success: false, error: 'tweets must be a non-empty array' });
128
+ return;
129
+ }
130
+ // Normalize: accept string[] or { text, mediaIds? }[]
131
+ const normalized = tweets.map((t) => typeof t === 'string' ? { text: t, mediaIds: undefined } : t);
132
+ // Validate character limits using X's weighted counting (emojis=2, URLs=23, CJK=2)
133
+ const CHAR_LIMIT = 25000;
134
+ const overLimit = normalized.map((t, i) => ({ i, len: parseTweet(t.text).weightedLength })).filter(x => x.len > CHAR_LIMIT);
135
+ if (overLimit.length > 0) {
136
+ res.status(400).json({
137
+ success: false,
138
+ error: `${overLimit.length} tweet(s) exceed ${CHAR_LIMIT} chars: ${overLimit.map(x => `#${x.i + 1} (${x.len})`).join(', ')}`,
139
+ });
140
+ return;
141
+ }
142
+ // Validate mediaIds per tweet
143
+ for (let i = 0; i < normalized.length; i++) {
144
+ const ids = normalized[i].mediaIds;
145
+ if (ids && (!Array.isArray(ids) || ids.length > 4)) {
146
+ res.status(400).json({ success: false, error: `Tweet ${i + 1}: mediaIds must be an array of 1-4 IDs` });
147
+ return;
148
+ }
149
+ }
150
+ const client = createXClient(ctx.config);
151
+ if (!client) {
152
+ res.status(400).json({ success: false, error: 'X API credentials not configured' });
153
+ return;
154
+ }
155
+ const postedTweets = [];
156
+ let previousTweetId = replyTo;
157
+ for (let i = 0; i < normalized.length; i++) {
158
+ const { text, mediaIds } = normalized[i];
159
+ const body = {};
160
+ if (text)
161
+ body.text = text;
162
+ if (previousTweetId) {
163
+ body.reply = { inReplyToTweetId: previousTweetId };
164
+ }
165
+ if (mediaIds && mediaIds.length > 0) {
166
+ body.media = { media_ids: mediaIds };
167
+ }
168
+ const result = await client.posts.create(body);
169
+ const tweetId = result?.data?.id;
170
+ if (!tweetId) {
171
+ res.status(500).json({
172
+ success: false,
173
+ postedTweets,
174
+ failedAt: i,
175
+ error: `Tweet ${i + 1} posted but no ID returned`,
176
+ });
177
+ return;
178
+ }
179
+ postedTweets.push({ index: i, tweetId, text });
180
+ previousTweetId = tweetId;
181
+ }
182
+ // Build thread URL from first tweet
183
+ const firstTweetId = postedTweets[0]?.tweetId;
184
+ const threadUrl = firstTweetId ? `https://x.com/i/status/${firstTweetId}` : undefined;
185
+ console.log(`[X Plugin] Thread posted: ${postedTweets.length} tweets, ${threadUrl}`);
186
+ res.json({ success: true, postedTweets, threadUrl });
187
+ }
188
+ catch (err) {
189
+ console.error('[X Plugin] Post thread failed:', err.message);
190
+ res.status(500).json({ success: false, error: err.message });
191
+ }
192
+ });
193
+ // POST /api/x/upload-media — upload a local /_images/ file for tweet attachment
194
+ ctx.app.post('/api/x/upload-media', async (req, res) => {
195
+ try {
196
+ const { src } = req.body;
197
+ if (!src || typeof src !== 'string') {
198
+ res.status(400).json({ success: false, error: 'src is required' });
199
+ return;
200
+ }
201
+ // Security: only allow /_images/ paths, no traversal
202
+ if (!/^\/_images\/[^/\\]+$/.test(src)) {
203
+ res.status(400).json({ success: false, error: 'Invalid image path — must be /_images/<filename>' });
204
+ return;
205
+ }
206
+ const filename = src.replace('/_images/', '');
207
+ const filePath = join(ctx.dataDir, '_images', filename);
208
+ if (!existsSync(filePath)) {
209
+ res.status(404).json({ success: false, error: `Image not found: ${filename}` });
210
+ return;
211
+ }
212
+ const ext = extname(filename).toLowerCase();
213
+ const mimeMap = {
214
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
215
+ '.png': 'image/png', '.webp': 'image/webp',
216
+ '.gif': 'image/jpeg', '.bmp': 'image/bmp',
217
+ '.tiff': 'image/tiff', '.tif': 'image/tiff',
218
+ };
219
+ const mediaType = mimeMap[ext] || 'image/jpeg';
220
+ const client = createXClient(ctx.config);
221
+ if (!client) {
222
+ res.status(400).json({ success: false, error: 'X API credentials not configured' });
223
+ return;
224
+ }
225
+ let fileBuffer = readFileSync(filePath);
226
+ let uploadType = mediaType;
227
+ const origSize = fileBuffer.length;
228
+ // Compress large images or PNGs to JPEG to stay under X API limits
229
+ if (fileBuffer.length > 3 * 1024 * 1024 || ext === '.png') {
230
+ fileBuffer = Buffer.from(await sharp(fileBuffer).jpeg({ quality: 85 }).toBuffer());
231
+ uploadType = 'image/jpeg';
232
+ console.log(`[X Plugin] Compressed ${filename}: ${(origSize / 1024 / 1024).toFixed(2)}MB → ${(fileBuffer.length / 1024 / 1024).toFixed(2)}MB`);
233
+ }
234
+ console.log(`[X Plugin] Uploading ${filename}: ${(fileBuffer.length / 1024 / 1024).toFixed(2)}MB, type: ${uploadType}`);
235
+ const mediaBase64 = fileBuffer.toString('base64');
236
+ const uploadResult = await client.media.upload({
237
+ body: { media: mediaBase64, mediaCategory: 'tweet_image', mediaType: uploadType },
238
+ });
239
+ const mediaId = uploadResult?.data?.id
240
+ || uploadResult?.media_id_string;
241
+ if (!mediaId) {
242
+ res.status(500).json({ success: false, error: 'Upload succeeded but no media ID returned' });
243
+ return;
244
+ }
245
+ console.log(`[X Plugin] Media uploaded: ${filename} → ${mediaId}`);
246
+ res.json({ success: true, mediaId });
247
+ }
248
+ catch (err) {
249
+ console.error('[X Plugin] Media upload failed:', err.message);
250
+ if (err.response) {
251
+ try {
252
+ const body = await err.response.text();
253
+ console.error('[X Plugin] X API response:', err.response.status, body);
254
+ }
255
+ catch { /* ignore */ }
256
+ }
257
+ if (err.data)
258
+ console.error('[X Plugin] Error data:', JSON.stringify(err.data));
259
+ console.error('[X Plugin] Full error:', JSON.stringify(err, Object.getOwnPropertyNames(err)));
260
+ res.status(500).json({ success: false, error: err.message });
261
+ }
262
+ });
263
+ // POST /api/x/post-article — publish the active document as a native X
264
+ // Article. Two-step X flow: draft, then publish. Reads the active server
265
+ // doc (same source the managed/publish path reads) and converts it to X's
266
+ // DraftJS content_state via the shared converter.
267
+ ctx.app.post('/api/x/post-article', async (_req, res) => {
268
+ try {
269
+ const oauth1 = createOAuth1(ctx.config);
270
+ if (!oauth1) {
271
+ res.status(400).json({ success: false, error: 'X API credentials not configured' });
272
+ return;
273
+ }
274
+ const bridge = await getServerBridge();
275
+ const doc = bridge.getDocument();
276
+ const title = (bridge.getTitle() || '').trim();
277
+ const metadata = bridge.getMetadata() || {};
278
+ if (!title || title === 'Untitled') {
279
+ res.status(400).json({ success: false, error: 'Article needs a title before posting.' });
280
+ return;
281
+ }
282
+ const contentState = bridge.tiptapToDraftjs(doc);
283
+ if (!contentState.blocks.some((b) => (b.text || '').trim().length > 0)) {
284
+ res.status(400).json({ success: false, error: 'Article body is empty.' });
285
+ return;
286
+ }
287
+ // Optional cover image — upload to X and attach as cover_media.
288
+ let coverMedia;
289
+ const coverSrc = metadata?.articleContext?.coverImage;
290
+ if (coverSrc && typeof coverSrc === 'string' && /^\/_images\/[^/\\]+$/.test(coverSrc)) {
291
+ const client = createXClient(ctx.config);
292
+ if (client) {
293
+ const mediaId = await uploadCoverMedia(client, ctx.dataDir, coverSrc);
294
+ if (mediaId)
295
+ coverMedia = { media_category: 'TWEET_IMAGE', media_id: mediaId };
296
+ }
297
+ }
298
+ // Step 1 — create the draft.
299
+ const draftBody = { title, content_state: contentState };
300
+ if (coverMedia)
301
+ draftBody.cover_media = coverMedia;
302
+ const draft = await xApiFetch(oauth1, 'POST', '/articles/draft', draftBody);
303
+ if (!draft.ok) {
304
+ const detail = draft.data?.detail || draft.data?.title || JSON.stringify(draft.data);
305
+ console.error('[X Plugin] Article draft failed:', draft.status, detail);
306
+ res.status(draft.status || 500).json({ success: false, error: `Draft failed: ${detail}` });
307
+ return;
308
+ }
309
+ const articleId = draft.data?.data?.id;
310
+ if (!articleId) {
311
+ res.status(500).json({ success: false, error: 'Draft created but no article id returned.' });
312
+ return;
313
+ }
314
+ // Step 2 — publish the draft (makes it public). Surface any error
315
+ // verbatim — a draft is private until this succeeds.
316
+ const published = await xApiFetch(oauth1, 'POST', `/articles/${articleId}/publish`);
317
+ if (!published.ok) {
318
+ const detail = published.data?.detail || published.data?.title || JSON.stringify(published.data);
319
+ console.error('[X Plugin] Article publish failed:', published.status, detail);
320
+ res.status(published.status || 500).json({ success: false, articleId, error: `Publish failed: ${detail}` });
321
+ return;
322
+ }
323
+ const postId = published.data?.data?.post_id;
324
+ const articleUrl = postId ? `https://x.com/i/status/${postId}` : undefined;
325
+ console.log(`[X Plugin] Article published: ${articleId} -> ${articleUrl}`);
326
+ res.json({ success: true, articleId, postId, articleUrl });
327
+ }
328
+ catch (err) {
329
+ const detail = err.data ? JSON.stringify(err.data) : err.message;
330
+ console.error('[X Plugin] Post article failed:', detail);
331
+ res.status(500).json({ success: false, error: detail });
332
+ }
333
+ });
334
+ },
335
+ };
336
+ /** Upload an article cover image to X, returning its media_id. Mirrors the
337
+ * /api/x/upload-media compression rules (>3MB or PNG -> JPEG). Returns null on
338
+ * any failure — the article still posts, just without a cover. */
339
+ async function uploadCoverMedia(client, dataDir, src) {
340
+ try {
341
+ const filename = src.replace('/_images/', '');
342
+ const filePath = join(dataDir, '_images', filename);
343
+ if (!existsSync(filePath))
344
+ return null;
345
+ const ext = extname(filename).toLowerCase();
346
+ const mimeMap = {
347
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
348
+ '.png': 'image/png', '.webp': 'image/webp',
349
+ '.gif': 'image/jpeg', '.bmp': 'image/bmp',
350
+ '.tiff': 'image/tiff', '.tif': 'image/tiff',
351
+ };
352
+ let fileBuffer = readFileSync(filePath);
353
+ let uploadType = mimeMap[ext] || 'image/jpeg';
354
+ if (fileBuffer.length > 3 * 1024 * 1024 || ext === '.png') {
355
+ fileBuffer = Buffer.from(await sharp(fileBuffer).jpeg({ quality: 85 }).toBuffer());
356
+ uploadType = 'image/jpeg';
357
+ }
358
+ const uploadResult = await client.media.upload({
359
+ body: { media: fileBuffer.toString('base64'), mediaCategory: 'tweet_image', mediaType: uploadType },
360
+ });
361
+ return uploadResult?.data?.id || uploadResult?.media_id_string || null;
362
+ }
363
+ catch (err) {
364
+ console.error('[X Plugin] Cover upload failed:', err.message);
365
+ return null;
366
+ }
367
+ }
368
+ export default plugin;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Thin runtime bridge to the OpenWriter server modules this plugin needs for
3
+ * article publishing — the active document, its title/metadata, the data dir,
4
+ * and the shared TipTap -> DraftJS converter.
5
+ *
6
+ * The converter (server/tiptap-draftjs.ts) is shared with plugins/publish so
7
+ * both posting paths produce identical X content_state. We import the compiled
8
+ * server module at runtime rather than vendoring it, resolving both the npm
9
+ * package layout and the monorepo layout — the same dual-path trick the publish
10
+ * plugin's helpers use.
11
+ */
12
+ export interface ServerBridge {
13
+ getDocument: () => any;
14
+ getTitle: () => string;
15
+ getMetadata: () => Record<string, any>;
16
+ getDataDir: () => string;
17
+ tiptapToDraftjs: (doc: any) => {
18
+ blocks: any[];
19
+ entities: any[];
20
+ };
21
+ }
22
+ export declare function getServerBridge(): Promise<ServerBridge>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Thin runtime bridge to the OpenWriter server modules this plugin needs for
3
+ * article publishing — the active document, its title/metadata, the data dir,
4
+ * and the shared TipTap -> DraftJS converter.
5
+ *
6
+ * The converter (server/tiptap-draftjs.ts) is shared with plugins/publish so
7
+ * both posting paths produce identical X content_state. We import the compiled
8
+ * server module at runtime rather than vendoring it, resolving both the npm
9
+ * package layout and the monorepo layout — the same dual-path trick the publish
10
+ * plugin's helpers use.
11
+ */
12
+ // npm package: dist/plugins/x-api/dist/server-bridge.js -> dist/server/
13
+ // monorepo: plugins/x-api/dist/server-bridge.js -> packages/openwriter/dist/server/
14
+ const npmBase = new URL('../../../server/', import.meta.url).href;
15
+ const monoBase = new URL('../../../packages/openwriter/dist/server/', import.meta.url).href;
16
+ let cached = null;
17
+ async function tryImport(base) {
18
+ const [state, helpers, draftjs] = await Promise.all([
19
+ import(base + 'state.js'),
20
+ import(base + 'helpers.js'),
21
+ import(base + 'tiptap-draftjs.js'),
22
+ ]);
23
+ return { state, helpers, draftjs };
24
+ }
25
+ export async function getServerBridge() {
26
+ if (cached)
27
+ return cached;
28
+ let state, helpers, draftjs;
29
+ try {
30
+ ({ state, helpers, draftjs } = await tryImport(npmBase));
31
+ }
32
+ catch {
33
+ ({ state, helpers, draftjs } = await tryImport(monoBase));
34
+ }
35
+ cached = {
36
+ getDocument: state.getDocument,
37
+ getTitle: state.getTitle,
38
+ getMetadata: state.getMetadata,
39
+ getDataDir: helpers.getDataDir,
40
+ tiptapToDraftjs: draftjs.tiptapToDraftjs,
41
+ };
42
+ return cached;
43
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@openwriter/plugin-x-api",
3
+ "version": "0.1.0",
4
+ "description": "Post tweets from OpenWriter",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "dev": "tsc --watch"
10
+ },
11
+ "dependencies": {
12
+ "@xdevplatform/xdk": "^0.4.0",
13
+ "twitter-text": "^3.1.0"
14
+ },
15
+ "devDependencies": {
16
+ "@types/express": "^5.0.0",
17
+ "typescript": "^5.6.0"
18
+ },
19
+ "openwriter": {
20
+ "displayName": "X / Twitter",
21
+ "category": "social-media"
22
+ },
23
+ "files": [
24
+ "dist/",
25
+ "package.json"
26
+ ]
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.40.1",
3
+ "version": "0.40.2",
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",
@@ -0,0 +1,180 @@
1
+ # Enrichment Dispatch — Detailed Procedure
2
+
3
+ OpenWriter's frontmatter enrichment is dispatched via the
4
+ `openwriter-enrichment-minion` custom subagent. SKILL.md firm rule 5
5
+ covers the common case (single minion, small/medium batch). This doc
6
+ handles the **large-corpus case** where one minion isn't enough — and
7
+ the parallel-dispatch pattern that scales it.
8
+
9
+ ## When to chunk
10
+
11
+ | Dirty docs (N) | Dispatch shape | Wall time |
12
+ |---|---|---|
13
+ | 1–30 | Single minion. Default prompt. | ~10–45 seconds |
14
+ | 31+ | Chunked parallel minions. | ~30 seconds (regardless of N) |
15
+
16
+ The minion's turn budget (`maxTurns: 500` in its frontmatter) can handle
17
+ ~50 docs serially, but at that size the wall-clock cost (3+ minutes)
18
+ becomes visible to the user. Parallel dispatch keeps total wall time
19
+ under ~30 seconds for any corpus size up to a few hundred docs.
20
+
21
+ ## Step-by-step (large corpus)
22
+
23
+ ### 1. Inventory the work
24
+
25
+ ```
26
+ mcp__openwriter__list_dirty_docs()
27
+ ```
28
+
29
+ Returns every dirty doc across all workspaces with `docId`, `title`,
30
+ `workspaceFile`, `reason`. If `total ≤ 30`, stop — single minion path
31
+ (firm rule 5) is correct. If `total > 30`, continue.
32
+
33
+ ### 2. Chunk the work
34
+
35
+ v0.19.0 simplified the minion to logline-only — workspace vocab is no
36
+ longer relevant (the `domain` field that used it was dropped). You can
37
+ group chunks however you want; workspace-grouping is no longer required.
38
+ Practical defaults:
39
+
40
+ **Target: 12–15 docs per chunk.**
41
+
42
+ - **Very large dirty list (>100 docs):** split into chunks of ~15.
43
+ - **Workspace-grouped is still fine** if it makes the dispatch prompts
44
+ easier to read, but it's no longer a performance concern.
45
+
46
+ You'll typically land on 4–10 chunks. Don't exceed ~10 parallel —
47
+ Anthropic per-account rate limits kick in beyond that and you get
48
+ serialized anyway.
49
+
50
+ ### 3. Dispatch all chunks in one message
51
+
52
+ Send **every chunk in a single assistant message** with multiple `Agent`
53
+ tool uses. This is the only way they actually run in parallel —
54
+ sequential `Agent` calls block each other.
55
+
56
+ Use `run_in_background: true` so you can keep talking to the user while
57
+ the minions work. You'll receive a `<task-notification>` per chunk as
58
+ each one finishes.
59
+
60
+ ### 4. Prompt format (explicit-list mode)
61
+
62
+ The minion's agent file (`~/.claude/agents/openwriter-enrichment-minion.md`)
63
+ supports an explicit-list mode — pass docIds in the prompt and the minion
64
+ skips `list_dirty_docs` and uses your list directly.
65
+
66
+ Example prompt for one chunk (v0.19.0 — logline-only):
67
+
68
+ ```
69
+ Enrich these specific openwriter docs:
70
+
71
+ - a1b2c3d4 — Onboarding Email Sequence
72
+ - e5f6a7b8 — Why We Sleep — Ch 2 Notes
73
+ - 9z8y7x6w — Product Launch Checklist
74
+ - 1q2w3e4r — Ch 3 — Beats
75
+ - 5t6y7u8i — Ch 4 — Draft
76
+
77
+ For each: read_pad to get the body, write a logline ≤150 chars, then
78
+ bulk mark_enriched at the end with { docId, logline } per entry.
79
+ ```
80
+
81
+ Keep prompts short. The minion already knows the procedure from its
82
+ agent file — you're just handing it the work list. The minion's tool
83
+ allowlist (v0.19.0) is `list_dirty_docs`, `read_pad`, `mark_enriched`
84
+ — `get_workspace_structure` is no longer needed because there's no
85
+ workspace-vocab dependency.
86
+
87
+ ### 5. Surface to the user (large-batch phrasing)
88
+
89
+ Before dispatching, tell the user what's happening. Firm rule 5's
90
+ "large batch" tier (N > 20) requires a heads-up. Example:
91
+
92
+ > OpenWriter detected 73 docs that haven't been summarized yet —
93
+ > first-time setup. Refreshing them in 6 parallel batches in the
94
+ > background; this'll take ~30 seconds and a few cents of Haiku usage.
95
+
96
+ Then dispatch. Stay silent as notifications come in unless one fails.
97
+ When all are done, report once:
98
+
99
+ > Enrichment complete: 73 docs across 8 workspaces. Cost: ~$0.15.
100
+
101
+ ### 6. Verify completion
102
+
103
+ After every chunk has notified, call `list_dirty_docs` once more. If
104
+ `total > 0`, some docs slipped — usually because a minion errored on a
105
+ specific doc or a doc was modified mid-enrichment. Re-dispatch a single
106
+ minion for the stragglers; don't redo the whole batch.
107
+
108
+ ## Why this shape
109
+
110
+ **Why parallel, not serial single-minion at maxTurns: 500?**
111
+ A single minion processing 100 docs takes 3+ minutes wall time. The
112
+ user sits in silence. Six parallel minions of ~15 docs each finish in
113
+ ~30 seconds. Same total token cost — much better UX.
114
+
115
+ **Why explicit docId list instead of letting each minion call `list_dirty_docs`?**
116
+ Race conditions. If you spawn 6 minions and they all call
117
+ `list_dirty_docs`, they all see the same 100 dirty docs and try to
118
+ enrich the same docs in parallel. Most enrichments succeed (last write
119
+ wins on the frontmatter), but it's wasteful and the per-doc baselines
120
+ get computed multiple times. Explicit lists partition the work cleanly.
121
+
122
+ **Why 12–15 docs per chunk and not 50?**
123
+ Two reasons: (1) turn budget — each doc costs ~1 turn (one `read_pad`
124
+ call); ~15 docs leaves headroom inside the 500-turn ceiling even with
125
+ retries. (2) failure isolation — if one minion's batch errors, you lose
126
+ 15 docs of work, not 50.
127
+
128
+ **Why dispatch in one message, not sequential Agent calls?**
129
+ Sequential `Agent` calls block each other. Only multiple `Agent` tool
130
+ uses in the **same assistant message** run truly in parallel.
131
+
132
+ ## Cost ballpark
133
+
134
+ Haiku token cost per doc: ~1.5K–3K in v0.19.0 (one read_pad + one
135
+ logline synthesis + share of mark_enriched). Roughly half what it cost
136
+ under v0.16's five-field schema.
137
+
138
+ | Corpus size | Approx cost (v0.19.0) |
139
+ |---|---|
140
+ | 30 docs | ~$0.02 |
141
+ | 100 docs | ~$0.08 |
142
+ | 500 docs | ~$0.40 |
143
+
144
+ Compare to ~$5.00 per doc if you used the general-purpose subagent with
145
+ full MCP tool registry (~50K token overhead per spawn). The custom
146
+ minion's tool allowlist (3 tools in v0.19.0: `list_dirty_docs`,
147
+ `read_pad`, `mark_enriched`) is what makes the math work.
148
+
149
+ ## Failure modes
150
+
151
+ - **Minion returns with no `mark_enriched` call** — almost always means
152
+ it hit the turn ceiling. Confirm its agent file has `maxTurns: 500`
153
+ in the frontmatter, then reduce chunk size to ~10 docs and re-dispatch
154
+ that chunk.
155
+ - **Minion reports "No enrichment work pending"** — its assigned docs
156
+ got enriched by a sibling minion first (race condition from
157
+ `list_dirty_docs` mode, not explicit-list mode). Benign; the other
158
+ minion's work landed correctly.
159
+ - **`<task-notification>` reports an error** — re-dispatch just that
160
+ one chunk. Don't restart the whole batch.
161
+ - **Logline cap violations** — the minion's agent file enforces a
162
+ 150-char hard cap. If you spot longer loglines on disk after the
163
+ fact, it's a minion regression — flag for agent-file revision rather
164
+ than re-enriching.
165
+
166
+ ## When NOT to chunk
167
+
168
+ If `list_dirty_docs` returns ≤30 docs, dispatch a single minion with
169
+ the default prompt:
170
+
171
+ ```
172
+ Agent({
173
+ subagent_type: "openwriter-enrichment-minion",
174
+ description: "Enrich stale openwriter docs",
175
+ prompt: "Enrich all currently stale openwriter docs."
176
+ })
177
+ ```
178
+
179
+ The minion calls `list_dirty_docs` itself, processes everything in one
180
+ pass, and reports back. Chunking ≤30 docs is overhead, not gain.