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
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post History Sync — pulls scheduler_history from platform,
|
|
3
|
+
* updates local doc frontmatter with lastPost metadata.
|
|
4
|
+
*
|
|
5
|
+
* The platform is the canonical record for posted items.
|
|
6
|
+
* Local frontmatter is a cached view for sidebar display.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import matter from 'gray-matter';
|
|
11
|
+
import { filenameByDocId } from './documents.js';
|
|
12
|
+
import { getDataDir } from './helpers.js';
|
|
13
|
+
import { isAuthenticated, platformFetch } from './connections.js';
|
|
14
|
+
import { isExternalDoc } from './helpers.js';
|
|
15
|
+
/**
|
|
16
|
+
* Sync posted history from platform → local doc frontmatter.
|
|
17
|
+
* For each history item with a doc_id:
|
|
18
|
+
* - Find the local doc by docId
|
|
19
|
+
* - Check if frontmatter already has a lastPost >= posted_at
|
|
20
|
+
* - If not, write the appropriate context metadata
|
|
21
|
+
*/
|
|
22
|
+
export async function syncPostHistory() {
|
|
23
|
+
if (!isAuthenticated())
|
|
24
|
+
return { synced: 0, skipped: 0, errors: 0 };
|
|
25
|
+
let items;
|
|
26
|
+
try {
|
|
27
|
+
const res = await platformFetch('/scheduler/history');
|
|
28
|
+
if (!res.ok)
|
|
29
|
+
return { synced: 0, skipped: 0, errors: 0 };
|
|
30
|
+
const data = await res.json();
|
|
31
|
+
items = data.items || [];
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { synced: 0, skipped: 0, errors: 0 };
|
|
35
|
+
}
|
|
36
|
+
let synced = 0, skipped = 0, errors = 0;
|
|
37
|
+
for (const item of items) {
|
|
38
|
+
if (!item.doc_id || !item.result?.success || !item.posted_at) {
|
|
39
|
+
skipped++;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const filename = filenameByDocId(item.doc_id);
|
|
44
|
+
if (!filename) {
|
|
45
|
+
skipped++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const filePath = isExternalDoc(filename)
|
|
49
|
+
? filename
|
|
50
|
+
: join(getDataDir(), filename);
|
|
51
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
52
|
+
const { data, content } = matter(raw);
|
|
53
|
+
// Determine the context key based on content_type/provider
|
|
54
|
+
const provider = item.provider || item.content_type;
|
|
55
|
+
let contextKey;
|
|
56
|
+
let lastPostField;
|
|
57
|
+
let urlField;
|
|
58
|
+
if (provider === 'x' || item.content_type === 'tweet' || item.content_type === 'thread') {
|
|
59
|
+
contextKey = 'tweetContext';
|
|
60
|
+
lastPostField = 'lastPost';
|
|
61
|
+
urlField = 'tweetUrl';
|
|
62
|
+
}
|
|
63
|
+
else if (provider === 'linkedin' || item.content_type === 'linkedin') {
|
|
64
|
+
contextKey = 'linkedinContext';
|
|
65
|
+
lastPostField = 'lastPost';
|
|
66
|
+
urlField = 'postUrl';
|
|
67
|
+
}
|
|
68
|
+
else if (item.content_type === 'blog' || provider === 'github') {
|
|
69
|
+
contextKey = 'blogContext';
|
|
70
|
+
lastPostField = 'lastPublish';
|
|
71
|
+
urlField = 'publishUrl';
|
|
72
|
+
}
|
|
73
|
+
else if (item.content_type === 'newsletter') {
|
|
74
|
+
// Newsletter uses its own sync path
|
|
75
|
+
skipped++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
contextKey = 'tweetContext';
|
|
80
|
+
lastPostField = 'lastPost';
|
|
81
|
+
urlField = 'tweetUrl';
|
|
82
|
+
}
|
|
83
|
+
// Check if already synced (existing lastPost >= this posted_at)
|
|
84
|
+
const existingContext = data[contextKey] || {};
|
|
85
|
+
const existingPost = existingContext[lastPostField];
|
|
86
|
+
if (existingPost?.postedAt) {
|
|
87
|
+
const existingTime = new Date(existingPost.postedAt).getTime();
|
|
88
|
+
const newTime = new Date(item.posted_at).getTime();
|
|
89
|
+
if (existingTime >= newTime) {
|
|
90
|
+
skipped++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Write updated metadata
|
|
95
|
+
data[contextKey] = {
|
|
96
|
+
...existingContext,
|
|
97
|
+
[lastPostField]: {
|
|
98
|
+
postedAt: item.posted_at,
|
|
99
|
+
...(item.result.url ? { [urlField]: item.result.url } : {}),
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const updated = matter.stringify(content, data);
|
|
103
|
+
writeFileSync(filePath, updated, 'utf-8');
|
|
104
|
+
synced++;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
errors++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (synced > 0) {
|
|
111
|
+
console.log(`[PostSync] Synced ${synced} posted items to local docs`);
|
|
112
|
+
}
|
|
113
|
+
return { synced, skipped, errors };
|
|
114
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { Router } from 'express';
|
|
5
5
|
import { platformFetch, isAuthenticated } from './connections.js';
|
|
6
|
+
import { syncPostHistory } from './post-sync.js';
|
|
6
7
|
export function createSchedulerRouter() {
|
|
7
8
|
const router = Router();
|
|
8
9
|
function proxy(path, method = 'GET') {
|
|
@@ -115,8 +116,40 @@ export function createSchedulerRouter() {
|
|
|
115
116
|
});
|
|
116
117
|
// History
|
|
117
118
|
router.get('/api/scheduler/history', proxy('/scheduler/history'));
|
|
119
|
+
// Sync post history from platform → local doc frontmatter
|
|
120
|
+
router.post('/api/scheduler/sync', async (_req, res) => {
|
|
121
|
+
try {
|
|
122
|
+
const result = await syncPostHistory();
|
|
123
|
+
res.json(result);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
res.status(500).json({ error: err.message });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
118
129
|
// Available connections for scheduler
|
|
119
130
|
router.get('/api/scheduler/connections', proxy('/scheduler/connections'));
|
|
131
|
+
// Upload media via connection (proxies to platform)
|
|
132
|
+
router.post('/api/connections/:id/upload-media', async (req, res) => {
|
|
133
|
+
try {
|
|
134
|
+
if (!isAuthenticated()) {
|
|
135
|
+
res.json({ error: 'Not authenticated' });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const upstream = await platformFetch(`/connections/${req.params.id}/upload-media`, {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
body: JSON.stringify(req.body),
|
|
141
|
+
});
|
|
142
|
+
const data = await upstream.json();
|
|
143
|
+
if (!upstream.ok) {
|
|
144
|
+
res.status(upstream.status).json(data);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
res.json(data);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
res.status(500).json({ error: err.message });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
120
153
|
// --- Autoplugs ---
|
|
121
154
|
// Goals
|
|
122
155
|
router.get('/api/scheduler/autoplugs/goals', proxy('/scheduler/autoplugs/goals'));
|
package/dist/server/state.js
CHANGED
|
@@ -92,7 +92,7 @@ export function getDocId() {
|
|
|
92
92
|
export function getPlainText() {
|
|
93
93
|
return extractText(state.document.content);
|
|
94
94
|
}
|
|
95
|
-
function extractText(nodes) {
|
|
95
|
+
export function extractText(nodes) {
|
|
96
96
|
if (!nodes)
|
|
97
97
|
return '';
|
|
98
98
|
return nodes
|
|
@@ -170,21 +170,21 @@ function computePartialRange(origContent, newContent) {
|
|
|
170
170
|
let origTo = origEnd >= firstDiff && origEnd < origWords.length ? origWords[origEnd].end : origFrom;
|
|
171
171
|
let newFrom = firstDiff < newWords.length ? newWords[firstDiff].start : newText.length;
|
|
172
172
|
let newTo = newEnd >= firstDiff && newEnd < newWords.length ? newWords[newEnd].end : newFrom;
|
|
173
|
-
// Snap start back to previous sentence boundary (after ". ")
|
|
173
|
+
// Snap start back to previous sentence boundary (after ". " or ".\n")
|
|
174
174
|
const snapBack = (text, pos) => {
|
|
175
175
|
let i = pos - 1;
|
|
176
176
|
while (i > 0) {
|
|
177
|
-
if (text[i] === '.' && i + 1 < text.length && text[i + 1] === ' ')
|
|
177
|
+
if (text[i] === '.' && i + 1 < text.length && (text[i + 1] === ' ' || text[i + 1] === '\n'))
|
|
178
178
|
return i + 2;
|
|
179
179
|
i--;
|
|
180
180
|
}
|
|
181
181
|
return 0; // No period found → start of text
|
|
182
182
|
};
|
|
183
|
-
// Snap end forward to next sentence boundary (
|
|
183
|
+
// Snap end forward to next sentence boundary (". " or ".\n" or end of text)
|
|
184
184
|
const snapForward = (text, pos) => {
|
|
185
185
|
let i = pos;
|
|
186
186
|
while (i < text.length) {
|
|
187
|
-
if (text[i] === '.' && (i + 1 >= text.length || text[i + 1] === ' '))
|
|
187
|
+
if (text[i] === '.' && (i + 1 >= text.length || text[i + 1] === ' ' || text[i + 1] === '\n'))
|
|
188
188
|
return i + 1;
|
|
189
189
|
i++;
|
|
190
190
|
}
|
|
@@ -249,31 +249,67 @@ export function getNodesByIds(ids) {
|
|
|
249
249
|
}
|
|
250
250
|
return result;
|
|
251
251
|
}
|
|
252
|
+
/** Pure version of getNodesByIds — takes content array instead of reading state. */
|
|
253
|
+
export function findNodesByIds(docContent, ids) {
|
|
254
|
+
const result = [];
|
|
255
|
+
const idSet = new Set(ids);
|
|
256
|
+
function scan(nodes) {
|
|
257
|
+
if (!nodes)
|
|
258
|
+
return;
|
|
259
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
260
|
+
const node = nodes[i];
|
|
261
|
+
if (node.attrs?.id && idSet.has(node.attrs.id)) {
|
|
262
|
+
result.push(node);
|
|
263
|
+
if (i + 1 < nodes.length && nodes[i + 1].type === 'horizontalRule') {
|
|
264
|
+
result.push(nodes[i + 1]);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (node.content)
|
|
268
|
+
scan(node.content);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
scan(docContent);
|
|
272
|
+
if (result.length > 0 && result[result.length - 1].type === 'horizontalRule') {
|
|
273
|
+
result.pop();
|
|
274
|
+
}
|
|
275
|
+
return result;
|
|
276
|
+
}
|
|
252
277
|
export function getMetadata() {
|
|
253
278
|
return state.metadata;
|
|
254
279
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Apply contamination guards + deep-merge context keys. Pure function.
|
|
282
|
+
* Returns the merged metadata object, or null if all updates were filtered out.
|
|
283
|
+
*/
|
|
284
|
+
export function mergeMetadataUpdates(existing, updates) {
|
|
285
|
+
// Clone so we don't mutate the caller's object
|
|
286
|
+
updates = { ...updates };
|
|
287
|
+
// Prevent blogContext contamination
|
|
288
|
+
if (updates.blogContext && !updates.blogContext.active && !existing?.blogContext?.active) {
|
|
259
289
|
delete updates.blogContext;
|
|
260
290
|
if (Object.keys(updates).length === 0)
|
|
261
|
-
return;
|
|
291
|
+
return null;
|
|
262
292
|
}
|
|
263
293
|
// Same guard for newsletterContext
|
|
264
|
-
if (updates.newsletterContext && !updates.newsletterContext.active && !
|
|
294
|
+
if (updates.newsletterContext && !updates.newsletterContext.active && !existing?.newsletterContext?.active) {
|
|
265
295
|
delete updates.newsletterContext;
|
|
266
296
|
if (Object.keys(updates).length === 0)
|
|
267
|
-
return;
|
|
297
|
+
return null;
|
|
268
298
|
}
|
|
269
|
-
// Deep-merge known context objects
|
|
299
|
+
// Deep-merge known context objects
|
|
270
300
|
const CONTEXT_KEYS = ['blogContext', 'newsletterContext', 'articleContext', 'tweetContext', 'linkedinContext'];
|
|
271
301
|
for (const key of CONTEXT_KEYS) {
|
|
272
|
-
if (updates[key] && typeof updates[key] === 'object' &&
|
|
273
|
-
updates[key] = { ...
|
|
302
|
+
if (updates[key] && typeof updates[key] === 'object' && existing?.[key] && typeof existing[key] === 'object') {
|
|
303
|
+
updates[key] = { ...existing[key], ...updates[key] };
|
|
274
304
|
}
|
|
275
305
|
}
|
|
276
|
-
|
|
306
|
+
return { ...existing, ...updates };
|
|
307
|
+
}
|
|
308
|
+
export function setMetadata(updates) {
|
|
309
|
+
const merged = mergeMetadataUpdates(state.metadata, updates);
|
|
310
|
+
if (!merged)
|
|
311
|
+
return;
|
|
312
|
+
state.metadata = merged;
|
|
277
313
|
if (updates.title)
|
|
278
314
|
state.title = updates.title;
|
|
279
315
|
// Auto-tag based on context metadata
|
|
@@ -599,6 +635,26 @@ function applyChangesToDoc(doc, changes) {
|
|
|
599
635
|
const found = findNode(doc.content, change.nodeId, doc.content);
|
|
600
636
|
if (!found)
|
|
601
637
|
continue;
|
|
638
|
+
// Tweet thread: hard-delete paragraphs + adjacent HR immediately.
|
|
639
|
+
// Tweet compose view can't handle pending deletes near HRs — hard-delete and resync.
|
|
640
|
+
const delNode = found.parent[found.index];
|
|
641
|
+
if (delNode.type === 'paragraph' && state.metadata?.tweetContext) {
|
|
642
|
+
const idx = found.index;
|
|
643
|
+
if (idx > 0 && found.parent[idx - 1].type === 'horizontalRule') {
|
|
644
|
+
found.parent.splice(idx, 1);
|
|
645
|
+
found.parent.splice(idx - 1, 1);
|
|
646
|
+
}
|
|
647
|
+
else if (idx + 1 < found.parent.length && found.parent[idx + 1].type === 'horizontalRule') {
|
|
648
|
+
found.parent.splice(idx + 1, 1);
|
|
649
|
+
found.parent.splice(idx, 1);
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
found.parent.splice(idx, 1);
|
|
653
|
+
}
|
|
654
|
+
// Push a synthetic HR change so ws.ts detects it and sends document-switched
|
|
655
|
+
processed.push({ operation: 'delete', nodeId: change.nodeId, content: [{ type: 'horizontalRule' }] });
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
602
658
|
found.parent[found.index] = {
|
|
603
659
|
...found.parent[found.index],
|
|
604
660
|
attrs: {
|
|
@@ -769,7 +825,7 @@ export function invalidateDocCache(filePath) {
|
|
|
769
825
|
docCache.delete(filePath);
|
|
770
826
|
}
|
|
771
827
|
/** Update the cache entry for a file after writing changes (without cloning the active state). */
|
|
772
|
-
function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
|
|
828
|
+
export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
|
|
773
829
|
let fileMtime = 0;
|
|
774
830
|
try {
|
|
775
831
|
fileMtime = statSync(filePath).mtimeMs;
|
|
@@ -1320,7 +1376,7 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
|
|
|
1320
1376
|
* Returns { title, wordCount, pendingCount } for the response message.
|
|
1321
1377
|
*/
|
|
1322
1378
|
/** Count pending nodes in a document tree. */
|
|
1323
|
-
function countPending(nodes) {
|
|
1379
|
+
export function countPending(nodes) {
|
|
1324
1380
|
let count = 0;
|
|
1325
1381
|
if (!nodes)
|
|
1326
1382
|
return 0;
|
|
@@ -246,6 +246,41 @@ export function reorderContainer(wsFile, containerId, afterIdentifier) {
|
|
|
246
246
|
writeWorkspace(wsFile, ws);
|
|
247
247
|
return ws;
|
|
248
248
|
}
|
|
249
|
+
export function moveContainer(wsFile, containerId, targetContainerId, afterIdentifier) {
|
|
250
|
+
const ws = getWorkspace(wsFile);
|
|
251
|
+
moveNode(ws.root, containerId, targetContainerId, afterIdentifier);
|
|
252
|
+
writeWorkspace(wsFile, ws);
|
|
253
|
+
return ws;
|
|
254
|
+
}
|
|
255
|
+
export function reorderWorkspaceAfter(filename, afterFilename) {
|
|
256
|
+
ensureWorkspacesDir();
|
|
257
|
+
const order = readOrder();
|
|
258
|
+
// Ensure all current workspace files are in the order array
|
|
259
|
+
const files = readdirSync(getWorkspacesDir()).filter(f => f.endsWith('.json') && f !== '_order.json');
|
|
260
|
+
for (const f of files) {
|
|
261
|
+
if (!order.includes(f))
|
|
262
|
+
order.push(f);
|
|
263
|
+
}
|
|
264
|
+
// Remove target
|
|
265
|
+
const idx = order.indexOf(filename);
|
|
266
|
+
if (idx === -1)
|
|
267
|
+
throw new Error(`Workspace "${filename}" not found in order`);
|
|
268
|
+
order.splice(idx, 1);
|
|
269
|
+
// Insert
|
|
270
|
+
if (afterFilename === null) {
|
|
271
|
+
order.unshift(filename);
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
const afterIdx = order.indexOf(afterFilename);
|
|
275
|
+
if (afterIdx === -1) {
|
|
276
|
+
order.push(filename);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
order.splice(afterIdx + 1, 0, filename);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
writeOrder(order);
|
|
283
|
+
}
|
|
249
284
|
// ============================================================================
|
|
250
285
|
// CONTEXT
|
|
251
286
|
// ============================================================================
|
package/dist/server/ws.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* WebSocket handler: pushes NodeChanges to browser, receives doc updates + signals.
|
|
3
3
|
*/
|
|
4
4
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
|
-
import { updateDocument, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, onChanges, isAgentLocked, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, } from './state.js';
|
|
5
|
+
import { updateDocument, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, onChanges, isAgentLocked, setAgentLock, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, } from './state.js';
|
|
6
6
|
import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile } from './documents.js';
|
|
7
7
|
import { removeDocFromAllWorkspaces } from './workspaces.js';
|
|
8
8
|
const clients = new Set();
|
|
@@ -61,6 +61,10 @@ export function setupWebSocket(server) {
|
|
|
61
61
|
if (hasHrChange) {
|
|
62
62
|
const doc = getDocument();
|
|
63
63
|
console.log(`[WS] HR detected in tweet thread → sending document-switched (${doc?.content?.length || 0} nodes)`);
|
|
64
|
+
// Re-set agent lock so the 3s window starts NOW, not from the original insert.
|
|
65
|
+
// Tweet thread resyncs recreate all editors which fire onUpdate → stale doc-updates.
|
|
66
|
+
// Without this reset, the lock expires before the browser finishes recreating editors.
|
|
67
|
+
setAgentLock();
|
|
64
68
|
const filePath = getFilePath();
|
|
65
69
|
const filename = filePath ? filePath.split(/[/\\]/).pop() || '' : '';
|
|
66
70
|
const msg = JSON.stringify({
|
|
@@ -128,7 +132,13 @@ export function setupWebSocket(server) {
|
|
|
128
132
|
saveDocToFile(msg.filename, msg.document);
|
|
129
133
|
}
|
|
130
134
|
else {
|
|
131
|
-
|
|
135
|
+
// Strip ephemeral imageLoading nodes — they're transient placeholders that should
|
|
136
|
+
// never persist. The browser's doc-update can re-add them after a failed rewrite.
|
|
137
|
+
if (msg.document.content) {
|
|
138
|
+
msg.document.content = msg.document.content.filter((n) => n.type !== 'imageLoading');
|
|
139
|
+
}
|
|
140
|
+
const cleanedCount = msg.document.content?.length || 0;
|
|
141
|
+
console.log(`[WS] doc-update ACCEPTED (browser: ${nodeCount} nodes, cleaned: ${cleanedCount}, server: ${currentNodeCount} nodes)`);
|
|
132
142
|
updateDocument(msg.document);
|
|
133
143
|
updatePendingCacheForActiveDoc(); // Keep cache in sync after browser edits/reject-all
|
|
134
144
|
debouncedSave();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"@tiptap/starter-kit": "^3.0.0",
|
|
60
60
|
"@turbodocx/html-to-docx": "^1.20.1",
|
|
61
61
|
"@types/multer": "^2.0.0",
|
|
62
|
+
"@xdevplatform/xdk": "^0.4.0",
|
|
62
63
|
"express": "^4.21.0",
|
|
63
64
|
"gray-matter": "^4.0.3",
|
|
64
65
|
"lowlight": "^3.3.0",
|
|
@@ -72,8 +73,8 @@
|
|
|
72
73
|
"react": "^18.3.1",
|
|
73
74
|
"react-dom": "^18.3.1",
|
|
74
75
|
"trash": "^10.1.0",
|
|
76
|
+
"twitter-text": "^3.1.0",
|
|
75
77
|
"ws": "^8.18.0",
|
|
76
|
-
"@xdevplatform/xdk": "^0.4.0",
|
|
77
78
|
"zod": "^3.25.76"
|
|
78
79
|
},
|
|
79
80
|
"devDependencies": {
|
|
@@ -81,6 +82,7 @@
|
|
|
81
82
|
"@types/markdown-it": "^14.1.2",
|
|
82
83
|
"@types/react": "^18.3.0",
|
|
83
84
|
"@types/react-dom": "^18.3.0",
|
|
85
|
+
"@types/twitter-text": "^3.1.10",
|
|
84
86
|
"@types/ws": "^8.5.0",
|
|
85
87
|
"@vitejs/plugin-react": "^4.3.0",
|
|
86
88
|
"typescript": "^5.6.0",
|
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.3.0"
|
|
20
20
|
repository: https://github.com/travsteward/openwriter
|
|
21
21
|
license: MIT
|
|
22
22
|
---
|
|
@@ -96,6 +96,8 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
96
96
|
- Two documents can have the same title — the docId disambiguates
|
|
97
97
|
- Filenames contain UUIDs unrelated to docIds — the first segment of a filename UUID looks like a docId but is not
|
|
98
98
|
|
|
99
|
+
**MCP params:** `metadata`, `changes`, `content` are objects — never stringify them.
|
|
100
|
+
|
|
99
101
|
## MCP Tools Reference (36 core + 21 publish platform)
|
|
100
102
|
|
|
101
103
|
### Document Operations
|
|
@@ -147,7 +149,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
147
149
|
| `delete_container` | Delete a container from a workspace (doc files stay on disk) |
|
|
148
150
|
| `tag_doc` | Add a tag to a document by docId (stored in doc frontmatter) |
|
|
149
151
|
| `untag_doc` | Remove a tag from a document by docId |
|
|
150
|
-
| `
|
|
152
|
+
| `move_item` | Move or reorder a doc, container, or workspace (type: doc/container/workspace) |
|
|
151
153
|
| `rename_item` | Rename a workspace, container, or document (type: workspace/container/document) |
|
|
152
154
|
|
|
153
155
|
### Agent Marks
|
|
@@ -161,14 +163,13 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
161
163
|
|
|
162
164
|
| Tool | Key Params | Description |
|
|
163
165
|
|------|-----------|-------------|
|
|
164
|
-
| `edit_text` | `docId`, `nodeId`, `edits` | Fine-grained text edits within a node (find/replace, add/remove marks) |
|
|
166
|
+
| `edit_text` | `docId`, `nodeId`, `edits` | Fine-grained text edits within a node (find/replace, add/remove marks). **`edits` must be a JSON array, not a string.** Example: `edits: [{ find: "old text", replace: "new text" }]` |
|
|
165
167
|
|
|
166
168
|
### Image Generation
|
|
167
169
|
|
|
168
170
|
| Tool | Description |
|
|
169
171
|
|------|-------------|
|
|
170
|
-
| `
|
|
171
|
-
| `insert_image` | Insert an image into the document at a specific position (from URL or local path) |
|
|
172
|
+
| `insert_image` | Generate image via Gemini. Three modes: (1) `docId` + `afterNodeId` → inline insert with pending decoration. (2) `set_cover: true` → set as article cover. (3) Neither → generate to disk only. Requires GEMINI_API_KEY. |
|
|
172
173
|
|
|
173
174
|
### Version Management
|
|
174
175
|
|
|
@@ -228,7 +229,7 @@ create_document({
|
|
|
228
229
|
- **`container`** (string) — container name within the workspace (e.g. "Chapters", "Notes", "References"). Auto-creates if not found. Requires `workspace`.
|
|
229
230
|
- Both are optional — omit for standalone docs outside any workspace.
|
|
230
231
|
|
|
231
|
-
This eliminates the need for separate `create_workspace`, `create_container`, and `
|
|
232
|
+
This eliminates the need for separate `create_workspace`, `create_container`, and `move_item` calls when building up a workspace.
|
|
232
233
|
|
|
233
234
|
## Workflow
|
|
234
235
|
|
|
@@ -395,6 +396,17 @@ Threads are single documents with `horizontalRule` nodes separating each tweet.
|
|
|
395
396
|
}})
|
|
396
397
|
```
|
|
397
398
|
|
|
399
|
+
### Inserting New Tweets into Existing Threads
|
|
400
|
+
|
|
401
|
+
**Insert HR + paragraph as ONE change with a content array.** Two separate calls will fail — the browser resyncs on HR insertion and overwrites the second call.
|
|
402
|
+
|
|
403
|
+
```
|
|
404
|
+
write_to_pad({ docId: "...", changes: [
|
|
405
|
+
{ operation: "insert", afterNodeId: "<last-node-of-previous-tweet>",
|
|
406
|
+
content: [{ type: "horizontalRule" }, { type: "paragraph", content: [{ type: "text", text: "New tweet" }] }] }
|
|
407
|
+
]})
|
|
408
|
+
```
|
|
409
|
+
|
|
398
410
|
### Paragraph Spacing in Tweets
|
|
399
411
|
|
|
400
412
|
Tweet compose uses `<br>` (hardBreak) for line breaks within a paragraph. Double Enter in the browser creates a new `<p>` node (paragraph split) with visual spacing.
|
|
@@ -440,6 +452,16 @@ After creating a thread, use `read_pad` to get node IDs, then `insert_image` to
|
|
|
440
452
|
|
|
441
453
|
All `insert_image` calls can run **in parallel** — no dependencies between them. Images appear with green pending decorations for user review.
|
|
442
454
|
|
|
455
|
+
### Inserting Existing Images (from disk)
|
|
456
|
+
|
|
457
|
+
Copy to `~/.openwriter/profiles/Default/_images/`, then use TipTap JSON in `write_to_pad`:
|
|
458
|
+
|
|
459
|
+
```
|
|
460
|
+
content: { "type": "image", "attrs": { "src": "/_images/my-image.png", "alt": "..." } }
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
**Markdown `` does NOT work** — creates an empty paragraph. Always use TipTap JSON.
|
|
464
|
+
|
|
443
465
|
## Review Etiquette
|
|
444
466
|
|
|
445
467
|
1. **Share the URL.** Always tell the user: http://localhost:5050
|