openwriter 0.6.11 → 0.8.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-02FEqxwZ.js +210 -0
- package/dist/client/assets/index-D9laiJ2-.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/publish/dist/index.js +244 -13
- package/dist/plugins/x-api/dist/index.js +4 -2
- package/dist/plugins/x-api/package.json +2 -1
- package/dist/server/compact.js +39 -1
- package/dist/server/connection-routes.js +8 -1
- package/dist/server/index.js +3 -0
- package/dist/server/mcp.js +315 -220
- package/dist/server/post-sync.js +114 -0
- package/dist/server/scheduler-routes.js +33 -0
- package/dist/server/state.js +74 -18
- package/dist/server/workspaces.js +35 -0
- package/dist/server/ws.js +12 -2
- package/package.json +4 -2
- package/skill/SKILL.md +28 -6
- package/dist/client/assets/index-BFXmrfky.js +0 -210
- package/dist/client/assets/index-DnndZMJ9.css +0 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getServerModules, publishFetch } from './helpers.js';
|
|
2
2
|
import { newsletterTools } from './newsletter-tools.js';
|
|
3
|
+
import { readFileSync, existsSync } from 'fs';
|
|
4
|
+
import { join, extname } from 'path';
|
|
3
5
|
const plugin = {
|
|
4
6
|
name: '@openwriter/plugin-publish',
|
|
5
7
|
version: '0.1.0',
|
|
@@ -332,33 +334,166 @@ const plugin = {
|
|
|
332
334
|
// --- Scheduler tools ---
|
|
333
335
|
{
|
|
334
336
|
name: 'schedule_post',
|
|
335
|
-
description: 'Schedule
|
|
337
|
+
description: 'Schedule the current document for posting. Reads content and content_type from the active document automatically. Default mode: queue (next available slot).',
|
|
336
338
|
inputSchema: {
|
|
337
339
|
type: 'object',
|
|
338
340
|
properties: {
|
|
339
|
-
|
|
340
|
-
connection_id: { type: 'string', description: 'Target connection ID (use list_connections to find)' },
|
|
341
|
-
content_type: { type: 'string', enum: ['tweet', 'x', 'linkedin', 'newsletter'], description: 'Content type' },
|
|
341
|
+
connection_id: { type: 'string', description: 'Target connection ID (use list_connections to find). If omitted, infers from content_type.' },
|
|
342
342
|
mode: { type: 'string', enum: ['queue', 'now', 'custom'], description: 'Scheduling mode (default: queue)' },
|
|
343
343
|
scheduled_at: { type: 'string', description: 'ISO datetime for custom mode' },
|
|
344
|
+
slot_id: { type: 'string', description: 'Specific slot ID to target (overrides automatic slot selection)' },
|
|
344
345
|
},
|
|
345
|
-
required: ['content', 'content_type'],
|
|
346
346
|
},
|
|
347
347
|
handler: async (params) => {
|
|
348
|
+
const server = await getServerModules();
|
|
349
|
+
const doc = server.getDocument();
|
|
350
|
+
const metadata = server.getMetadata();
|
|
351
|
+
const docId = server.getDocId();
|
|
352
|
+
if (!doc || !doc.content)
|
|
353
|
+
return { error: 'No active document. Switch to a document first.' };
|
|
354
|
+
// --- Helpers ---
|
|
355
|
+
const extractText = (nodes) => {
|
|
356
|
+
const walk = (node) => {
|
|
357
|
+
if (node.type === 'text')
|
|
358
|
+
return node.text || '';
|
|
359
|
+
if (node.type === 'hardBreak')
|
|
360
|
+
return '\n';
|
|
361
|
+
if (!node.content)
|
|
362
|
+
return '';
|
|
363
|
+
const inner = node.content.map(walk).join('');
|
|
364
|
+
if (node.type === 'paragraph')
|
|
365
|
+
return inner + '\n\n';
|
|
366
|
+
return inner;
|
|
367
|
+
};
|
|
368
|
+
return nodes.map(walk).join('').trim();
|
|
369
|
+
};
|
|
370
|
+
const extractImages = (nodes) => {
|
|
371
|
+
const srcs = [];
|
|
372
|
+
const walk = (node) => {
|
|
373
|
+
if (node.type === 'image' && node.attrs?.src)
|
|
374
|
+
srcs.push(node.attrs.src);
|
|
375
|
+
if (node.content)
|
|
376
|
+
node.content.forEach(walk);
|
|
377
|
+
};
|
|
378
|
+
nodes.forEach(walk);
|
|
379
|
+
return srcs.slice(0, 4); // X limit: 4 images per tweet
|
|
380
|
+
};
|
|
381
|
+
const mimeMap = {
|
|
382
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
383
|
+
'.png': 'image/png', '.webp': 'image/webp',
|
|
384
|
+
'.gif': 'image/gif', '.bmp': 'image/bmp',
|
|
385
|
+
};
|
|
386
|
+
const uploadImages = async (srcs, connId) => {
|
|
387
|
+
const ids = [];
|
|
388
|
+
const dataDir = server.getDataDir();
|
|
389
|
+
for (const src of srcs) {
|
|
390
|
+
if (!src.startsWith('/_images/'))
|
|
391
|
+
continue;
|
|
392
|
+
const filename = src.replace('/_images/', '');
|
|
393
|
+
const filePath = join(dataDir, '_images', filename);
|
|
394
|
+
if (!existsSync(filePath))
|
|
395
|
+
continue;
|
|
396
|
+
const ext = extname(filename).toLowerCase();
|
|
397
|
+
const mediaType = mimeMap[ext] || 'image/jpeg';
|
|
398
|
+
const mediaBase64 = readFileSync(filePath).toString('base64');
|
|
399
|
+
const res = await server.platformFetch(`/connections/${connId}/upload-media`, {
|
|
400
|
+
method: 'POST',
|
|
401
|
+
body: JSON.stringify({ media_base64: mediaBase64, media_type: mediaType }),
|
|
402
|
+
});
|
|
403
|
+
if (res.ok) {
|
|
404
|
+
const data = await res.json();
|
|
405
|
+
if (data.mediaId)
|
|
406
|
+
ids.push(data.mediaId);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return ids;
|
|
410
|
+
};
|
|
411
|
+
// --- Split doc at horizontalRule nodes (thread detection) ---
|
|
412
|
+
const docNodes = doc.content || [];
|
|
413
|
+
const tweetGroups = [[]];
|
|
414
|
+
for (const node of docNodes) {
|
|
415
|
+
if (node.type === 'horizontalRule') {
|
|
416
|
+
tweetGroups.push([]);
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
tweetGroups[tweetGroups.length - 1].push(node);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// Filter empty groups
|
|
423
|
+
const tweets = tweetGroups.filter(g => g.length > 0);
|
|
424
|
+
const isThread = tweets.length > 1;
|
|
425
|
+
// Derive content_type from metadata
|
|
426
|
+
const contentType = metadata?.content_type || 'tweet';
|
|
427
|
+
// Auto-find connection
|
|
428
|
+
let connectionId = params.connection_id;
|
|
429
|
+
if (!connectionId) {
|
|
430
|
+
const provider = contentType === 'linkedin' ? 'linkedin' : 'x';
|
|
431
|
+
const listRes = await server.platformFetch('/connections');
|
|
432
|
+
if (listRes.ok) {
|
|
433
|
+
const data = await listRes.json();
|
|
434
|
+
const conn = data.connections.find((c) => c.provider === provider && c.status === 'active');
|
|
435
|
+
if (conn)
|
|
436
|
+
connectionId = conn.id;
|
|
437
|
+
}
|
|
438
|
+
if (!connectionId)
|
|
439
|
+
return { error: `No active ${provider} connection found.` };
|
|
440
|
+
}
|
|
441
|
+
// --- Build content: single tweet or thread ---
|
|
442
|
+
let queueContent;
|
|
443
|
+
let totalMedia = 0;
|
|
444
|
+
if (isThread) {
|
|
445
|
+
const tweetData = [];
|
|
446
|
+
for (const group of tweets) {
|
|
447
|
+
const text = extractText(group);
|
|
448
|
+
if (!text)
|
|
449
|
+
continue;
|
|
450
|
+
const images = extractImages(group);
|
|
451
|
+
const mediaIds = images.length > 0 ? await uploadImages(images, connectionId) : [];
|
|
452
|
+
totalMedia += mediaIds.length;
|
|
453
|
+
tweetData.push(mediaIds.length > 0 ? { text, mediaIds } : { text });
|
|
454
|
+
}
|
|
455
|
+
if (tweetData.length === 0)
|
|
456
|
+
return { error: 'Document is empty.' };
|
|
457
|
+
queueContent = { tweets: tweetData };
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
const text = extractText(docNodes);
|
|
461
|
+
if (!text)
|
|
462
|
+
return { error: 'Document is empty.' };
|
|
463
|
+
const images = extractImages(docNodes);
|
|
464
|
+
const mediaIds = images.length > 0 ? await uploadImages(images, connectionId) : [];
|
|
465
|
+
totalMedia = mediaIds.length;
|
|
466
|
+
queueContent = mediaIds.length > 0 ? { text, mediaIds } : { text };
|
|
467
|
+
}
|
|
468
|
+
// --- Queue it ---
|
|
469
|
+
const body = {
|
|
470
|
+
content: queueContent,
|
|
471
|
+
content_type: isThread ? 'thread' : contentType,
|
|
472
|
+
connection_id: connectionId,
|
|
473
|
+
mode: params.mode || 'queue',
|
|
474
|
+
doc_id: docId,
|
|
475
|
+
};
|
|
476
|
+
if (params.scheduled_at)
|
|
477
|
+
body.scheduled_at = params.scheduled_at;
|
|
478
|
+
if (params.slot_id)
|
|
479
|
+
body.slot_id = params.slot_id;
|
|
348
480
|
const res = await publishFetch(config, '/scheduler/queue', {
|
|
349
481
|
method: 'POST',
|
|
350
|
-
body: JSON.stringify(
|
|
351
|
-
content: params.content,
|
|
352
|
-
content_type: params.content_type,
|
|
353
|
-
connection_id: params.connection_id,
|
|
354
|
-
mode: params.mode || 'queue',
|
|
355
|
-
scheduled_at: params.scheduled_at,
|
|
356
|
-
}),
|
|
482
|
+
body: JSON.stringify(body),
|
|
357
483
|
});
|
|
358
484
|
const data = await res.json();
|
|
359
485
|
if (!res.ok)
|
|
360
486
|
return { error: `Schedule failed: ${data.error || res.statusText}` };
|
|
361
|
-
return {
|
|
487
|
+
return {
|
|
488
|
+
success: true,
|
|
489
|
+
docId,
|
|
490
|
+
scheduled_at: data.item?.scheduled_at,
|
|
491
|
+
connection: connectionId,
|
|
492
|
+
mode: params.mode || 'queue',
|
|
493
|
+
type: isThread ? 'thread' : 'tweet',
|
|
494
|
+
tweetCount: isThread ? tweets.length : 1,
|
|
495
|
+
mediaCount: totalMedia,
|
|
496
|
+
};
|
|
362
497
|
},
|
|
363
498
|
},
|
|
364
499
|
{
|
|
@@ -691,6 +826,102 @@ const plugin = {
|
|
|
691
826
|
return { success: true, ...data };
|
|
692
827
|
},
|
|
693
828
|
},
|
|
829
|
+
{
|
|
830
|
+
name: 'naturalize_slots',
|
|
831
|
+
description: 'Scramble slot times so posts don\'t look scheduled. Splits multi-day slots into per-day slots with jittered times (+/- 15 min). Each day gets a slightly different minute offset. Run after creating slots at clean macro times.',
|
|
832
|
+
inputSchema: {
|
|
833
|
+
type: 'object',
|
|
834
|
+
properties: {
|
|
835
|
+
jitter_minutes: { type: 'number', description: 'Max jitter in minutes (default: 15). Each slot shifts by a random amount within this range.' },
|
|
836
|
+
},
|
|
837
|
+
},
|
|
838
|
+
handler: async (params) => {
|
|
839
|
+
const jitter = params.jitter_minutes || 15;
|
|
840
|
+
const ALL_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
|
841
|
+
// Fetch current slots
|
|
842
|
+
const listRes = await publishFetch(config, '/scheduler/slots');
|
|
843
|
+
const listData = await listRes.json();
|
|
844
|
+
if (!listRes.ok)
|
|
845
|
+
return { error: `Failed to list slots: ${listData.error || listRes.statusText}` };
|
|
846
|
+
const slots = listData.slots || [];
|
|
847
|
+
if (!slots.length)
|
|
848
|
+
return { error: 'No slots to naturalize' };
|
|
849
|
+
// Parse HH:MM:SS or HH:MM to total minutes
|
|
850
|
+
const parseTime = (t) => {
|
|
851
|
+
const [h, m] = t.split(':').map(Number);
|
|
852
|
+
return h * 60 + m;
|
|
853
|
+
};
|
|
854
|
+
// Format total minutes back to HH:MM
|
|
855
|
+
const formatTime = (mins) => {
|
|
856
|
+
const clamped = ((mins % 1440) + 1440) % 1440;
|
|
857
|
+
const h = Math.floor(clamped / 60);
|
|
858
|
+
const m = clamped % 60;
|
|
859
|
+
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
|
860
|
+
};
|
|
861
|
+
// Deterministic-ish jitter per day index and slot index
|
|
862
|
+
const jitterFor = (dayIdx, slotIdx) => {
|
|
863
|
+
const seed = (dayIdx * 7 + slotIdx * 13 + 3) % (jitter * 2 + 1);
|
|
864
|
+
return seed - jitter;
|
|
865
|
+
};
|
|
866
|
+
let created = 0;
|
|
867
|
+
let deleted = 0;
|
|
868
|
+
const results = [];
|
|
869
|
+
for (let si = 0; si < slots.length; si++) {
|
|
870
|
+
const slot = slots[si];
|
|
871
|
+
const days = slot.days || ['default'];
|
|
872
|
+
const expandedDays = days.includes('default') ? ALL_DAYS : days;
|
|
873
|
+
// Skip already single-day slots (already naturalized)
|
|
874
|
+
if (expandedDays.length === 1 && !days.includes('default')) {
|
|
875
|
+
// Still jitter the time if it's on a round number
|
|
876
|
+
const mins = parseTime(slot.time);
|
|
877
|
+
if (mins % 5 === 0) {
|
|
878
|
+
const dayIdx = ALL_DAYS.indexOf(expandedDays[0]);
|
|
879
|
+
const newMins = mins + jitterFor(dayIdx, si);
|
|
880
|
+
const editRes = await publishFetch(config, `/scheduler/slots/${slot.id}`, {
|
|
881
|
+
method: 'PATCH',
|
|
882
|
+
body: JSON.stringify({ time: formatTime(newMins) }),
|
|
883
|
+
});
|
|
884
|
+
if (editRes.ok) {
|
|
885
|
+
results.push(`${expandedDays[0]}: ${slot.time} → ${formatTime(newMins)}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
// Multi-day slot: delete and create per-day replacements
|
|
891
|
+
const baseMinutes = parseTime(slot.time);
|
|
892
|
+
const delRes = await publishFetch(config, `/scheduler/slots/${slot.id}`, { method: 'DELETE' });
|
|
893
|
+
if (delRes.ok)
|
|
894
|
+
deleted++;
|
|
895
|
+
for (let di = 0; di < expandedDays.length; di++) {
|
|
896
|
+
const day = expandedDays[di];
|
|
897
|
+
const dayIdx = ALL_DAYS.indexOf(day);
|
|
898
|
+
const offset = jitterFor(dayIdx, si);
|
|
899
|
+
const newTime = formatTime(baseMinutes + offset);
|
|
900
|
+
const createRes = await publishFetch(config, '/scheduler/slots', {
|
|
901
|
+
method: 'POST',
|
|
902
|
+
body: JSON.stringify({
|
|
903
|
+
time: newTime,
|
|
904
|
+
days: [day],
|
|
905
|
+
filter_type: slot.filter_type || 'any',
|
|
906
|
+
filter_value: slot.filter_value || null,
|
|
907
|
+
timezone: slot.timezone || 'America/Los_Angeles',
|
|
908
|
+
}),
|
|
909
|
+
});
|
|
910
|
+
if (createRes.ok) {
|
|
911
|
+
created++;
|
|
912
|
+
results.push(`${day}: ${slot.time} → ${newTime}`);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return {
|
|
917
|
+
success: true,
|
|
918
|
+
deleted,
|
|
919
|
+
created,
|
|
920
|
+
summary: `Naturalized ${deleted} multi-day slots into ${created} per-day slots`,
|
|
921
|
+
details: results,
|
|
922
|
+
};
|
|
923
|
+
},
|
|
924
|
+
},
|
|
694
925
|
];
|
|
695
926
|
},
|
|
696
927
|
};
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { Client, OAuth1 } from '@xdevplatform/xdk';
|
|
7
7
|
import { join, extname } from 'path';
|
|
8
8
|
import { readFileSync, existsSync } from 'fs';
|
|
9
|
+
import twitter from 'twitter-text';
|
|
10
|
+
const { parseTweet } = twitter;
|
|
9
11
|
function createXClient(config) {
|
|
10
12
|
const apiKey = config['api-key'] || process.env.X_API_KEY || '';
|
|
11
13
|
const apiSecret = config['api-secret'] || process.env.X_API_SECRET || '';
|
|
@@ -101,9 +103,9 @@ const plugin = {
|
|
|
101
103
|
}
|
|
102
104
|
// Normalize: accept string[] or { text, mediaIds? }[]
|
|
103
105
|
const normalized = tweets.map((t) => typeof t === 'string' ? { text: t, mediaIds: undefined } : t);
|
|
104
|
-
// Validate character limits
|
|
106
|
+
// Validate character limits using X's weighted counting (emojis=2, URLs=23, CJK=2)
|
|
105
107
|
const CHAR_LIMIT = 25000;
|
|
106
|
-
const overLimit = normalized.map((t, i) => ({ i, len: t.text.
|
|
108
|
+
const overLimit = normalized.map((t, i) => ({ i, len: parseTweet(t.text).weightedLength })).filter(x => x.len > CHAR_LIMIT);
|
|
107
109
|
if (overLimit.length > 0) {
|
|
108
110
|
res.status(400).json({
|
|
109
111
|
success: false,
|
package/dist/server/compact.js
CHANGED
|
@@ -177,7 +177,7 @@ function nodeToCompactLines(node, indent) {
|
|
|
177
177
|
lines.push(`${indent}${tag} ${text}`);
|
|
178
178
|
return lines;
|
|
179
179
|
}
|
|
180
|
-
export function toCompactFormat(doc, title, wordCount, pendingCount, docId) {
|
|
180
|
+
export function toCompactFormat(doc, title, wordCount, pendingCount, docId, metadata) {
|
|
181
181
|
const header = [
|
|
182
182
|
`title: ${title}`,
|
|
183
183
|
...(docId ? [`id: ${docId}`] : []),
|
|
@@ -185,12 +185,50 @@ export function toCompactFormat(doc, title, wordCount, pendingCount, docId) {
|
|
|
185
185
|
`pending: ${pendingCount}`,
|
|
186
186
|
'---',
|
|
187
187
|
];
|
|
188
|
+
const isTweet = !!metadata?.tweetContext;
|
|
188
189
|
const body = [];
|
|
189
190
|
for (const node of doc.content || []) {
|
|
190
191
|
body.push(...nodeToCompactLines(node, ''));
|
|
192
|
+
// Show char count after each tweet paragraph in a thread
|
|
193
|
+
if (isTweet && node.type === 'paragraph' && node.attrs?.id) {
|
|
194
|
+
const text = extractNodeText(node);
|
|
195
|
+
if (text.trim()) {
|
|
196
|
+
const chars = tweetWeightedLength(text);
|
|
197
|
+
const over = chars - 280;
|
|
198
|
+
body.push(over > 0 ? ` ⚠ ${chars}/280 (+${over})` : ` ✓ ${chars}/280`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
191
201
|
}
|
|
192
202
|
return [...header, ...body].join('\n');
|
|
193
203
|
}
|
|
204
|
+
/** Extract plain text from a node tree. */
|
|
205
|
+
function extractNodeText(node) {
|
|
206
|
+
if (!node)
|
|
207
|
+
return '';
|
|
208
|
+
if (node.type === 'text' && typeof node.text === 'string')
|
|
209
|
+
return node.text;
|
|
210
|
+
if (node.type === 'hardBreak')
|
|
211
|
+
return '\n';
|
|
212
|
+
if (node.content)
|
|
213
|
+
return node.content.map(extractNodeText).join('');
|
|
214
|
+
return '';
|
|
215
|
+
}
|
|
216
|
+
/** X-weighted char count using twitter-text. */
|
|
217
|
+
let _parseTweet = null;
|
|
218
|
+
function tweetWeightedLength(text) {
|
|
219
|
+
if (!_parseTweet) {
|
|
220
|
+
try {
|
|
221
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
222
|
+
const twitter = require('twitter-text');
|
|
223
|
+
_parseTweet = twitter.parseTweet || twitter.default?.parseTweet;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Fallback: plain char count
|
|
227
|
+
return text.length;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return _parseTweet(text).weightedLength;
|
|
231
|
+
}
|
|
194
232
|
/**
|
|
195
233
|
* Convert an array of TipTap nodes to compact tagged-line format.
|
|
196
234
|
* Used by get_nodes tool.
|
|
@@ -61,7 +61,14 @@ export function createConnectionRouter() {
|
|
|
61
61
|
router.delete('/api/connections/:id', async (req, res) => {
|
|
62
62
|
try {
|
|
63
63
|
const upstream = await platformFetch(`/connections/${req.params.id}`, { method: 'DELETE' });
|
|
64
|
-
const
|
|
64
|
+
const text = await upstream.text();
|
|
65
|
+
let data;
|
|
66
|
+
try {
|
|
67
|
+
data = JSON.parse(text);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
data = { error: text };
|
|
71
|
+
}
|
|
65
72
|
if (!upstream.ok) {
|
|
66
73
|
res.status(upstream.status).json(data);
|
|
67
74
|
return;
|
package/dist/server/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { TOOL_REGISTRY } from './mcp.js';
|
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
14
14
|
import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, clearAllCaches } from './state.js';
|
|
15
|
+
import { syncPostHistory } from './post-sync.js';
|
|
15
16
|
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename } from './documents.js';
|
|
16
17
|
import { createWorkspaceRouter } from './workspace-routes.js';
|
|
17
18
|
import { createLinkRouter } from './link-routes.js';
|
|
@@ -732,6 +733,8 @@ export async function startHttpServer(options = {}) {
|
|
|
732
733
|
resolve();
|
|
733
734
|
});
|
|
734
735
|
});
|
|
736
|
+
// Sync post history from platform (catch posts made while app was closed)
|
|
737
|
+
syncPostHistory().catch(() => { });
|
|
735
738
|
// Open browser unless --no-open or running as MCP stdio pipe
|
|
736
739
|
const isMcpStdio = !process.stdout.isTTY;
|
|
737
740
|
if (!options.noOpen && !isMcpStdio) {
|