openwriter 0.5.5 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/pad.js +3 -0
- package/dist/client/assets/index-CCMCrgTu.js +209 -0
- package/dist/client/assets/index-DN1_4Au6.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/connection-routes.js +327 -0
- package/dist/server/connections.js +35 -0
- package/dist/server/documents.js +30 -24
- package/dist/server/export-html-template.js +1 -1
- package/dist/server/export-routes.js +1 -1
- package/dist/server/gdoc-import.js +2 -2
- package/dist/server/git-sync.js +30 -30
- package/dist/server/helpers.js +175 -20
- package/dist/server/image-upload.js +10 -7
- package/dist/server/index.js +162 -5
- package/dist/server/install-skill.js +119 -2
- package/dist/server/marks.js +11 -11
- package/dist/server/mcp.js +90 -16
- package/dist/server/plugin-manager.js +2 -2
- package/dist/server/scheduler-routes.js +121 -0
- package/dist/server/state.js +126 -42
- package/dist/server/versions.js +6 -2
- package/dist/server/workspaces.js +7 -7
- package/dist/server/ws.js +15 -0
- package/package.json +1 -1
- package/skill/SKILL.md +106 -25
- package/dist/client/assets/index-97-SDxVw.js +0 -210
- package/dist/client/assets/index-BR_sMmFf.css +0 -1
package/dist/server/state.js
CHANGED
|
@@ -8,7 +8,7 @@ import { join } from 'path';
|
|
|
8
8
|
import matter from 'gray-matter';
|
|
9
9
|
import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
|
|
10
10
|
import { applyTextEditsToNode } from './text-edit.js';
|
|
11
|
-
import {
|
|
11
|
+
import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
|
|
12
12
|
import { snapshotIfNeeded, ensureDocId } from './versions.js';
|
|
13
13
|
const DEFAULT_DOC = {
|
|
14
14
|
type: 'doc',
|
|
@@ -27,18 +27,18 @@ const listeners = new Set();
|
|
|
27
27
|
// ============================================================================
|
|
28
28
|
// EXTERNAL DOCUMENT REGISTRY
|
|
29
29
|
// ============================================================================
|
|
30
|
-
|
|
30
|
+
function getExternalDocsFile() { return join(getDataDir(), 'external-docs.json'); }
|
|
31
31
|
const externalDocs = new Set();
|
|
32
32
|
function persistExternalDocs() {
|
|
33
33
|
try {
|
|
34
|
-
atomicWriteFileSync(
|
|
34
|
+
atomicWriteFileSync(getExternalDocsFile(), JSON.stringify([...externalDocs]));
|
|
35
35
|
}
|
|
36
36
|
catch { /* best-effort */ }
|
|
37
37
|
}
|
|
38
38
|
function loadExternalDocs() {
|
|
39
39
|
try {
|
|
40
|
-
if (existsSync(
|
|
41
|
-
const paths = JSON.parse(readFileSync(
|
|
40
|
+
if (existsSync(getExternalDocsFile())) {
|
|
41
|
+
const paths = JSON.parse(readFileSync(getExternalDocsFile(), 'utf-8'));
|
|
42
42
|
for (const p of paths) {
|
|
43
43
|
if (existsSync(p))
|
|
44
44
|
externalDocs.add(p);
|
|
@@ -125,34 +125,65 @@ export function getPendingChangeCount() {
|
|
|
125
125
|
}
|
|
126
126
|
export function getNodesByIds(ids) {
|
|
127
127
|
const result = [];
|
|
128
|
+
const idSet = new Set(ids);
|
|
128
129
|
function scan(nodes) {
|
|
129
130
|
if (!nodes)
|
|
130
131
|
return;
|
|
131
|
-
for (
|
|
132
|
-
|
|
132
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
133
|
+
const node = nodes[i];
|
|
134
|
+
if (node.attrs?.id && idSet.has(node.attrs.id)) {
|
|
133
135
|
result.push(node);
|
|
136
|
+
// Preserve horizontalRule separators between matched nodes (thread structure)
|
|
137
|
+
if (i + 1 < nodes.length && nodes[i + 1].type === 'horizontalRule') {
|
|
138
|
+
result.push(nodes[i + 1]);
|
|
139
|
+
}
|
|
134
140
|
}
|
|
135
141
|
if (node.content)
|
|
136
142
|
scan(node.content);
|
|
137
143
|
}
|
|
138
144
|
}
|
|
139
145
|
scan(state.document.content);
|
|
146
|
+
// Remove trailing horizontalRule (don't end with separator)
|
|
147
|
+
if (result.length > 0 && result[result.length - 1].type === 'horizontalRule') {
|
|
148
|
+
result.pop();
|
|
149
|
+
}
|
|
140
150
|
return result;
|
|
141
151
|
}
|
|
142
152
|
export function getMetadata() {
|
|
143
153
|
return state.metadata;
|
|
144
154
|
}
|
|
145
155
|
export function setMetadata(updates) {
|
|
156
|
+
// Prevent blogContext contamination: only allow blogContext writes if
|
|
157
|
+
// the incoming update has active:true OR the doc already has active blogContext
|
|
158
|
+
if (updates.blogContext && !updates.blogContext.active && !state.metadata?.blogContext?.active) {
|
|
159
|
+
delete updates.blogContext;
|
|
160
|
+
if (Object.keys(updates).length === 0)
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// Same guard for newsletterContext
|
|
164
|
+
if (updates.newsletterContext && !updates.newsletterContext.active && !state.metadata?.newsletterContext?.active) {
|
|
165
|
+
delete updates.newsletterContext;
|
|
166
|
+
if (Object.keys(updates).length === 0)
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// Deep-merge known context objects so partial updates preserve essential fields (active, format, etc.)
|
|
170
|
+
const CONTEXT_KEYS = ['blogContext', 'newsletterContext', 'articleContext', 'tweetContext', 'linkedinContext'];
|
|
171
|
+
for (const key of CONTEXT_KEYS) {
|
|
172
|
+
if (updates[key] && typeof updates[key] === 'object' && state.metadata?.[key] && typeof state.metadata[key] === 'object') {
|
|
173
|
+
updates[key] = { ...state.metadata[key], ...updates[key] };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
146
176
|
state.metadata = { ...state.metadata, ...updates };
|
|
147
177
|
if (updates.title)
|
|
148
178
|
state.title = updates.title;
|
|
149
|
-
// Auto-tag
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
179
|
+
// Auto-tag based on context metadata
|
|
180
|
+
const filename = state.filePath
|
|
181
|
+
? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
|
|
182
|
+
: '';
|
|
183
|
+
if (filename) {
|
|
184
|
+
// tweetContext / articleContext → "x" + mode tag
|
|
185
|
+
for (const key of ['tweetContext', 'articleContext']) {
|
|
186
|
+
if (key in updates) {
|
|
156
187
|
if (updates[key]) {
|
|
157
188
|
addDocTag(filename, 'x');
|
|
158
189
|
const mode = updates[key]?.mode || (key === 'articleContext' ? 'article' : undefined);
|
|
@@ -164,6 +195,20 @@ export function setMetadata(updates) {
|
|
|
164
195
|
}
|
|
165
196
|
}
|
|
166
197
|
}
|
|
198
|
+
// blogContext / linkedinContext / newsletterContext → single tag
|
|
199
|
+
const contextTags = {
|
|
200
|
+
blogContext: 'blog',
|
|
201
|
+
linkedinContext: 'linkedin',
|
|
202
|
+
newsletterContext: 'newsletter',
|
|
203
|
+
};
|
|
204
|
+
for (const [key, tag] of Object.entries(contextTags)) {
|
|
205
|
+
if (key in updates) {
|
|
206
|
+
if (updates[key])
|
|
207
|
+
addDocTag(filename, tag);
|
|
208
|
+
else
|
|
209
|
+
removeDocTag(filename, tag);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
167
212
|
}
|
|
168
213
|
}
|
|
169
214
|
export function getStatus() {
|
|
@@ -187,61 +232,85 @@ export function updateDocument(doc) {
|
|
|
187
232
|
console.error(`[State] BLOCKED destructive updateDocument: ${incomingNodes} nodes would replace ${currentNodes} nodes`);
|
|
188
233
|
return;
|
|
189
234
|
}
|
|
190
|
-
// Preserve pending attrs
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
235
|
+
// Preserve pending attrs from server state → incoming browser doc.
|
|
236
|
+
// The browser's PendingAttributes extension tracks pendingStatus in the TipTap
|
|
237
|
+
// document model, but transferPendingAttrs provides a safety net in case the
|
|
238
|
+
// browser's doc-update lost them (e.g. timing edge case, stale transaction).
|
|
239
|
+
const serverHadPending = hasPendingChanges();
|
|
240
|
+
if (serverHadPending) {
|
|
194
241
|
transferPendingAttrs(state.document, doc);
|
|
195
242
|
}
|
|
196
243
|
state.document = doc;
|
|
197
244
|
state.lastModified = new Date();
|
|
245
|
+
// Validate: if server had pending changes, verify they survived the transfer
|
|
246
|
+
if (serverHadPending && !hasPendingChanges()) {
|
|
247
|
+
console.error('[State] WARNING: pending changes lost after updateDocument — browser doc-update overwrote pending attrs');
|
|
248
|
+
}
|
|
198
249
|
}
|
|
199
250
|
/**
|
|
200
251
|
* Transfer pending attrs from source doc to target doc by matching node IDs.
|
|
201
|
-
* Copies
|
|
252
|
+
* Copies all pending-related attrs: status, original content, group ID,
|
|
253
|
+
* selection ranges, and text edits.
|
|
202
254
|
*/
|
|
203
255
|
function transferPendingAttrs(source, target) {
|
|
204
|
-
// Build a map of nodeId → pending attrs from source
|
|
256
|
+
// Build a map of nodeId → all pending attrs from source
|
|
205
257
|
const pendingMap = new Map();
|
|
206
258
|
function collectPending(nodes) {
|
|
207
259
|
if (!nodes)
|
|
208
260
|
return;
|
|
209
261
|
for (const node of nodes) {
|
|
210
262
|
if (node.attrs?.pendingStatus && node.attrs?.id) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
263
|
+
const entry = {
|
|
264
|
+
pendingStatus: node.attrs.pendingStatus,
|
|
265
|
+
};
|
|
266
|
+
// Copy all pending-related attrs if present
|
|
267
|
+
if (node.attrs.pendingOriginalContent != null)
|
|
268
|
+
entry.pendingOriginalContent = node.attrs.pendingOriginalContent;
|
|
269
|
+
if (node.attrs.pendingTextEdits != null)
|
|
270
|
+
entry.pendingTextEdits = node.attrs.pendingTextEdits;
|
|
271
|
+
if (node.attrs.pendingGroupId != null)
|
|
272
|
+
entry.pendingGroupId = node.attrs.pendingGroupId;
|
|
273
|
+
if (node.attrs.pendingSelectionFrom != null)
|
|
274
|
+
entry.pendingSelectionFrom = node.attrs.pendingSelectionFrom;
|
|
275
|
+
if (node.attrs.pendingSelectionTo != null)
|
|
276
|
+
entry.pendingSelectionTo = node.attrs.pendingSelectionTo;
|
|
277
|
+
if (node.attrs.pendingOriginalFrom != null)
|
|
278
|
+
entry.pendingOriginalFrom = node.attrs.pendingOriginalFrom;
|
|
279
|
+
if (node.attrs.pendingOriginalTo != null)
|
|
280
|
+
entry.pendingOriginalTo = node.attrs.pendingOriginalTo;
|
|
281
|
+
pendingMap.set(node.attrs.id, entry);
|
|
216
282
|
}
|
|
217
283
|
if (node.content)
|
|
218
284
|
collectPending(node.content);
|
|
219
285
|
}
|
|
220
286
|
}
|
|
221
287
|
collectPending(source.content);
|
|
288
|
+
if (pendingMap.size === 0)
|
|
289
|
+
return;
|
|
222
290
|
// Apply pending attrs to matching nodes in target
|
|
291
|
+
let transferred = 0;
|
|
223
292
|
function applyPending(nodes) {
|
|
224
293
|
if (!nodes)
|
|
225
294
|
return;
|
|
226
295
|
for (const node of nodes) {
|
|
227
296
|
if (node.attrs?.id && pendingMap.has(node.attrs.id)) {
|
|
228
297
|
const p = pendingMap.get(node.attrs.id);
|
|
229
|
-
node.attrs
|
|
230
|
-
|
|
231
|
-
node.attrs.pendingOriginalContent = p.original;
|
|
232
|
-
if (p.textEdits)
|
|
233
|
-
node.attrs.pendingTextEdits = p.textEdits;
|
|
298
|
+
Object.assign(node.attrs, p);
|
|
299
|
+
transferred++;
|
|
234
300
|
}
|
|
235
301
|
if (node.content)
|
|
236
302
|
applyPending(node.content);
|
|
237
303
|
}
|
|
238
304
|
}
|
|
239
305
|
applyPending(target.content);
|
|
306
|
+
if (transferred < pendingMap.size) {
|
|
307
|
+
console.warn(`[State] transferPendingAttrs: ${transferred}/${pendingMap.size} nodes matched — ${pendingMap.size - transferred} pending nodes missing in target doc`);
|
|
308
|
+
}
|
|
240
309
|
}
|
|
241
310
|
// ============================================================================
|
|
242
311
|
// AGENT WRITE LOCK
|
|
243
312
|
// ============================================================================
|
|
244
|
-
const AGENT_LOCK_MS =
|
|
313
|
+
const AGENT_LOCK_MS = 3000; // Block browser doc-updates for 3s after agent write
|
|
245
314
|
let lastAgentWriteTime = 0;
|
|
246
315
|
/** Set the agent write lock (called after agent changes). */
|
|
247
316
|
export function setAgentLock() {
|
|
@@ -514,10 +583,10 @@ export function setPendingCacheEntry(filename, count) {
|
|
|
514
583
|
function populatePendingCache() {
|
|
515
584
|
pendingDocCache.clear();
|
|
516
585
|
try {
|
|
517
|
-
const files = readdirSync(
|
|
586
|
+
const files = readdirSync(getDataDir()).filter((f) => f.endsWith('.md'));
|
|
518
587
|
for (const f of files) {
|
|
519
588
|
try {
|
|
520
|
-
const raw = readFileSync(join(
|
|
589
|
+
const raw = readFileSync(join(getDataDir(), f), 'utf-8');
|
|
521
590
|
const { data } = matter(raw);
|
|
522
591
|
if (data.pending && Object.keys(data.pending).length > 0) {
|
|
523
592
|
pendingDocCache.set(f, Object.keys(data.pending).length);
|
|
@@ -602,6 +671,21 @@ function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
|
|
|
602
671
|
fileMtime,
|
|
603
672
|
});
|
|
604
673
|
}
|
|
674
|
+
/** Reset all in-memory caches. Called on profile switch. */
|
|
675
|
+
export function clearAllCaches() {
|
|
676
|
+
docCache.clear();
|
|
677
|
+
pendingDocCache.clear();
|
|
678
|
+
externalDocs.clear();
|
|
679
|
+
state = {
|
|
680
|
+
document: DEFAULT_DOC,
|
|
681
|
+
title: 'Untitled',
|
|
682
|
+
metadata: { title: 'Untitled' },
|
|
683
|
+
filePath: '',
|
|
684
|
+
isTemp: true,
|
|
685
|
+
lastModified: new Date(),
|
|
686
|
+
docId: '',
|
|
687
|
+
};
|
|
688
|
+
}
|
|
605
689
|
// ============================================================================
|
|
606
690
|
// PENDING DOCUMENT STORE OPERATIONS
|
|
607
691
|
// ============================================================================
|
|
@@ -670,7 +754,7 @@ export function getPendingDocInfo() {
|
|
|
670
754
|
const stale = [];
|
|
671
755
|
for (const [filename, count] of pendingDocCache) {
|
|
672
756
|
// Validate file still exists on disk (prunes ghost entries after server restart)
|
|
673
|
-
const filePath = isExternalDoc(filename) ? filename : join(
|
|
757
|
+
const filePath = isExternalDoc(filename) ? filename : join(getDataDir(), filename);
|
|
674
758
|
if (!existsSync(filePath)) {
|
|
675
759
|
stale.push(filename);
|
|
676
760
|
continue;
|
|
@@ -742,10 +826,10 @@ export function load() {
|
|
|
742
826
|
// Clean up empty temp files from previous sessions
|
|
743
827
|
cleanupEmptyTempFiles();
|
|
744
828
|
// Find most recently modified .md file
|
|
745
|
-
const files = readdirSync(
|
|
829
|
+
const files = readdirSync(getDataDir())
|
|
746
830
|
.filter((f) => f.endsWith('.md'))
|
|
747
831
|
.map((f) => {
|
|
748
|
-
const fullPath = join(
|
|
832
|
+
const fullPath = join(getDataDir(), f);
|
|
749
833
|
const stat = statSync(fullPath);
|
|
750
834
|
return { name: f, path: fullPath, mtime: stat.mtimeMs };
|
|
751
835
|
})
|
|
@@ -795,11 +879,11 @@ export function load() {
|
|
|
795
879
|
/** Migrate legacy .sw.json files to .md format */
|
|
796
880
|
function migrateSwJsonFiles() {
|
|
797
881
|
try {
|
|
798
|
-
const jsonFiles = readdirSync(
|
|
882
|
+
const jsonFiles = readdirSync(getDataDir()).filter((f) => f.endsWith('.sw.json'));
|
|
799
883
|
for (const f of jsonFiles) {
|
|
800
|
-
const jsonPath = join(
|
|
884
|
+
const jsonPath = join(getDataDir(), f);
|
|
801
885
|
const mdName = f.replace(/\.sw\.json$/, '.md');
|
|
802
|
-
const mdPath = join(
|
|
886
|
+
const mdPath = join(getDataDir(), mdName);
|
|
803
887
|
// Skip if .md already exists
|
|
804
888
|
if (existsSync(mdPath)) {
|
|
805
889
|
try {
|
|
@@ -834,7 +918,7 @@ function migrateSwJsonFiles() {
|
|
|
834
918
|
function getWorkspaceReferencedFiles() {
|
|
835
919
|
const referenced = new Set();
|
|
836
920
|
try {
|
|
837
|
-
const wsDir = join(
|
|
921
|
+
const wsDir = join(getDataDir(), '_workspaces');
|
|
838
922
|
if (!existsSync(wsDir))
|
|
839
923
|
return referenced;
|
|
840
924
|
const manifests = readdirSync(wsDir).filter((f) => f.endsWith('.json'));
|
|
@@ -871,12 +955,12 @@ function getWorkspaceReferencedFiles() {
|
|
|
871
955
|
function cleanupEmptyTempFiles() {
|
|
872
956
|
try {
|
|
873
957
|
const wsRefs = getWorkspaceReferencedFiles();
|
|
874
|
-
const files = readdirSync(
|
|
958
|
+
const files = readdirSync(getDataDir()).filter((f) => f.startsWith(TEMP_PREFIX) && f.endsWith('.md'));
|
|
875
959
|
for (const f of files) {
|
|
876
960
|
// Never delete temp files that are referenced by a workspace
|
|
877
961
|
if (wsRefs.has(f))
|
|
878
962
|
continue;
|
|
879
|
-
const fullPath = join(
|
|
963
|
+
const fullPath = join(getDataDir(), f);
|
|
880
964
|
try {
|
|
881
965
|
const raw = readFileSync(fullPath, 'utf-8');
|
|
882
966
|
const parsed = markdownToTiptap(raw);
|
package/dist/server/versions.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, statSync } from 'fs';
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import { createHash } from 'crypto';
|
|
9
|
-
import {
|
|
9
|
+
import { getVersionsDir } from './helpers.js';
|
|
10
10
|
import { markdownToTiptap } from './markdown.js';
|
|
11
11
|
// ============================================================================
|
|
12
12
|
// DEDUP STATE
|
|
@@ -16,6 +16,10 @@ const MIN_INTERVAL_MS = 30_000; // 30 seconds between snapshots of same content
|
|
|
16
16
|
function contentHash(markdown) {
|
|
17
17
|
return createHash('sha256').update(markdown).digest('hex').slice(0, 16);
|
|
18
18
|
}
|
|
19
|
+
/** Clear dedup state. Called on profile switch. */
|
|
20
|
+
export function clearVersionsCache() {
|
|
21
|
+
lastSnapshot.clear();
|
|
22
|
+
}
|
|
19
23
|
// ============================================================================
|
|
20
24
|
// DOC ID
|
|
21
25
|
// ============================================================================
|
|
@@ -38,7 +42,7 @@ export function ensureDocId(metadata) {
|
|
|
38
42
|
// SNAPSHOT
|
|
39
43
|
// ============================================================================
|
|
40
44
|
function docDir(docId) {
|
|
41
|
-
return join(
|
|
45
|
+
return join(getVersionsDir(), docId);
|
|
42
46
|
}
|
|
43
47
|
function ensureDocDir(docId) {
|
|
44
48
|
const dir = docDir(docId);
|
|
@@ -8,16 +8,16 @@ import { join } from 'path';
|
|
|
8
8
|
import { randomUUID } from 'crypto';
|
|
9
9
|
import matter from 'gray-matter';
|
|
10
10
|
import trash from 'trash';
|
|
11
|
-
import {
|
|
11
|
+
import { getWorkspacesDir, ensureWorkspacesDir, sanitizeFilename, resolveDocPath, isExternalDoc } from './helpers.js';
|
|
12
12
|
import { markdownToTiptap, tiptapToMarkdown } from './markdown.js';
|
|
13
|
-
|
|
13
|
+
function getOrderFile() { return join(getWorkspacesDir(), '_order.json'); }
|
|
14
14
|
import { isV1, migrateV1toV2 } from './workspace-types.js';
|
|
15
15
|
import { addDocToContainer, addContainer as addContainerToTree, removeNode, moveNode, reorderNode, findContainer, collectAllFiles, countDocs, findDocNode } from './workspace-tree.js';
|
|
16
16
|
// ============================================================================
|
|
17
17
|
// INTERNAL HELPERS
|
|
18
18
|
// ============================================================================
|
|
19
19
|
function workspacePath(filename) {
|
|
20
|
-
return join(
|
|
20
|
+
return join(getWorkspacesDir(), filename);
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
23
|
* Migrate workspace-level tags into document frontmatter.
|
|
@@ -75,16 +75,16 @@ function writeWorkspace(filename, workspace) {
|
|
|
75
75
|
}
|
|
76
76
|
function readOrder() {
|
|
77
77
|
try {
|
|
78
|
-
if (!existsSync(
|
|
78
|
+
if (!existsSync(getOrderFile()))
|
|
79
79
|
return [];
|
|
80
|
-
return JSON.parse(readFileSync(
|
|
80
|
+
return JSON.parse(readFileSync(getOrderFile(), 'utf-8'));
|
|
81
81
|
}
|
|
82
82
|
catch {
|
|
83
83
|
return [];
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
function writeOrder(order) {
|
|
87
|
-
writeFileSync(
|
|
87
|
+
writeFileSync(getOrderFile(), JSON.stringify(order, null, 2), 'utf-8');
|
|
88
88
|
}
|
|
89
89
|
function readDocFrontmatter(filename) {
|
|
90
90
|
try {
|
|
@@ -104,7 +104,7 @@ function readDocFrontmatter(filename) {
|
|
|
104
104
|
// ============================================================================
|
|
105
105
|
export function listWorkspaces() {
|
|
106
106
|
ensureWorkspacesDir();
|
|
107
|
-
const files = readdirSync(
|
|
107
|
+
const files = readdirSync(getWorkspacesDir()).filter((f) => f.endsWith('.json') && f !== '_order.json');
|
|
108
108
|
const infos = files.map((f) => {
|
|
109
109
|
try {
|
|
110
110
|
const ws = readWorkspace(f);
|
package/dist/server/ws.js
CHANGED
|
@@ -160,6 +160,12 @@ export function setupWebSocket(server) {
|
|
|
160
160
|
title = 'Quote Tweet';
|
|
161
161
|
else if (tmpl === 'article')
|
|
162
162
|
title = 'Article';
|
|
163
|
+
else if (tmpl === 'linkedin')
|
|
164
|
+
title = 'LinkedIn Post';
|
|
165
|
+
else if (tmpl === 'newsletter')
|
|
166
|
+
title = 'Newsletter';
|
|
167
|
+
else if (tmpl === 'blog')
|
|
168
|
+
title = 'Blog Post';
|
|
163
169
|
const result = createDocument(title);
|
|
164
170
|
// Set template-specific metadata
|
|
165
171
|
if (tmpl === 'tweet') {
|
|
@@ -174,6 +180,15 @@ export function setupWebSocket(server) {
|
|
|
174
180
|
else if (tmpl === 'article') {
|
|
175
181
|
setMetadata({ articleContext: { active: true } });
|
|
176
182
|
}
|
|
183
|
+
else if (tmpl === 'linkedin') {
|
|
184
|
+
setMetadata({ linkedinContext: { active: true } });
|
|
185
|
+
}
|
|
186
|
+
else if (tmpl === 'newsletter') {
|
|
187
|
+
setMetadata({ newsletterContext: { active: true } });
|
|
188
|
+
}
|
|
189
|
+
else if (tmpl === 'blog') {
|
|
190
|
+
setMetadata({ blogContext: { active: true } });
|
|
191
|
+
}
|
|
177
192
|
save();
|
|
178
193
|
broadcastDocumentSwitched(result.document, getTitle(), result.filename, getMetadata());
|
|
179
194
|
broadcastDocumentsChanged();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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
|
@@ -3,9 +3,10 @@ name: openwriter
|
|
|
3
3
|
description: |
|
|
4
4
|
OpenWriter — the writing surface for AI agents. A markdown-native rich text
|
|
5
5
|
editor where agents write via MCP tools and users accept or reject changes
|
|
6
|
-
in-browser.
|
|
7
|
-
organization
|
|
8
|
-
|
|
6
|
+
in-browser. 36 core MCP tools for document editing, multi-doc workspaces,
|
|
7
|
+
and organization, plus 21 publish platform tools for newsletter, social
|
|
8
|
+
posting, and scheduling. Tweet compose mode for drafting replies/QTs with
|
|
9
|
+
pixel-accurate X/Twitter UI. Plain .md files on disk — no database, no lock-in.
|
|
9
10
|
|
|
10
11
|
Use when user says: "open writer", "openwriter", "write in openwriter",
|
|
11
12
|
"edit my document", "review my writing", "check the pad", "write me a doc",
|
|
@@ -14,7 +15,7 @@ description: |
|
|
|
14
15
|
Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
|
|
15
16
|
metadata:
|
|
16
17
|
author: travsteward
|
|
17
|
-
version: "0.
|
|
18
|
+
version: "0.2.0"
|
|
18
19
|
repository: https://github.com/travsteward/openwriter
|
|
19
20
|
license: MIT
|
|
20
21
|
---
|
|
@@ -35,30 +36,31 @@ Check whether the `openwriter` MCP tools are available (e.g. `read_pad`, `write_
|
|
|
35
36
|
|
|
36
37
|
### MCP tools ARE available (ready to use)
|
|
37
38
|
|
|
38
|
-
The user already has OpenWriter configured
|
|
39
|
+
The user already has OpenWriter configured. You're good to go.
|
|
39
40
|
|
|
40
41
|
**First action:** Share the browser URL:
|
|
41
42
|
> OpenWriter is at **http://localhost:5050** — open it in your browser to see and review changes.
|
|
42
43
|
|
|
43
44
|
Skip to [Writing Strategy](#writing-strategy) below.
|
|
44
45
|
|
|
45
|
-
### MCP tools are NOT available (
|
|
46
|
+
### MCP tools are NOT available (needs setup)
|
|
46
47
|
|
|
47
|
-
The user
|
|
48
|
+
The user has this skill but hasn't set up the MCP server yet. One command does everything:
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
```bash
|
|
51
|
+
npx openwriter install-skill
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This installs openwriter globally, configures the MCP server for Claude Code, and copies this skill — all in one step. After it finishes, the user just needs to restart their Claude Code session.
|
|
55
|
+
|
|
56
|
+
**Fallback (if the command above fails):** Do it manually:
|
|
50
57
|
|
|
51
58
|
```bash
|
|
52
|
-
# Install globally for instant startup (no npx resolution delay)
|
|
53
59
|
npm install -g openwriter
|
|
54
|
-
|
|
55
|
-
# Add the OpenWriter MCP server to Claude Code
|
|
56
60
|
claude mcp add -s user openwriter -- openwriter --no-open
|
|
57
61
|
```
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
**Step 2 (if the user can't run the command above):** Edit `~/.claude.json` directly. Add `openwriter` as the **first entry** in the `mcpServers` object — MCP servers load sequentially, so first in config = first to load:
|
|
63
|
+
If `claude mcp add` can't run (e.g. nested session error), edit `~/.claude.json` directly. Add `openwriter` as the **first entry** in `mcpServers`:
|
|
62
64
|
|
|
63
65
|
```json
|
|
64
66
|
{
|
|
@@ -71,14 +73,10 @@ Then restart the Claude Code session. The MCP tools become available on next lau
|
|
|
71
73
|
}
|
|
72
74
|
```
|
|
73
75
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
After editing, tell the user:
|
|
76
|
+
After setup, tell the user:
|
|
77
77
|
1. Restart your Claude Code session (MCP servers load on startup)
|
|
78
78
|
2. Open http://localhost:5050 in your browser
|
|
79
79
|
|
|
80
|
-
**Note:** You cannot run `claude mcp add` from inside a session (nested session error). That's why we edit the JSON directly when configuring from within Claude Code. Also, `claude mcp add` appends to the end — always verify the entry is first after adding.
|
|
81
|
-
|
|
82
80
|
## Document Identity: Titles vs DocIds
|
|
83
81
|
|
|
84
82
|
Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its YAML frontmatter. Titles are for human communication and agent reasoning. DocIds are for agent action.
|
|
@@ -87,7 +85,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
87
85
|
- All doc-targeting tools take `docId` as their parameter (not filename)
|
|
88
86
|
- Two documents can have the same title — the docId disambiguates
|
|
89
87
|
|
|
90
|
-
## MCP Tools Reference (
|
|
88
|
+
## MCP Tools Reference (36 core + 21 publish platform)
|
|
91
89
|
|
|
92
90
|
### Document Operations
|
|
93
91
|
|
|
@@ -110,6 +108,8 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
110
108
|
| `create_document` | `title?`, ... | Create a new empty document — response includes docId |
|
|
111
109
|
| `open_file` | `path` | Open an existing .md file from any location on disk |
|
|
112
110
|
| `delete_document` | `docId` | Delete a document file (moves to OS trash, recoverable) |
|
|
111
|
+
| `archive_document` | `docId` | Archive a document (hides from sidebar, keeps on disk) |
|
|
112
|
+
| `unarchive_document` | `docId` | Restore an archived document back to the sidebar |
|
|
113
113
|
|
|
114
114
|
### Import
|
|
115
115
|
|
|
@@ -132,11 +132,11 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
132
132
|
|
|
133
133
|
| Tool | Description |
|
|
134
134
|
|------|-------------|
|
|
135
|
-
| `add_doc` | Add a document to a workspace (optional container placement) |
|
|
136
135
|
| `create_container` | Create a folder inside a workspace (max depth: 3) |
|
|
137
|
-
| `
|
|
138
|
-
| `
|
|
139
|
-
| `
|
|
136
|
+
| `delete_container` | Delete a container from a workspace (doc files stay on disk) |
|
|
137
|
+
| `tag_doc` | Add a tag to a document by docId (stored in doc frontmatter) |
|
|
138
|
+
| `untag_doc` | Remove a tag from a document by docId |
|
|
139
|
+
| `move_doc` | Add a doc to a workspace, or move it within the workspace (by docId) |
|
|
140
140
|
| `rename_item` | Rename a workspace, container, or document (type: workspace/container/document) |
|
|
141
141
|
|
|
142
142
|
### Agent Marks
|
|
@@ -157,6 +157,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
|
|
|
157
157
|
| Tool | Description |
|
|
158
158
|
|------|-------------|
|
|
159
159
|
| `generate_image` | Generate an image via Gemini Nano Banana 2 — optionally set as article cover (requires GEMINI_API_KEY) |
|
|
160
|
+
| `insert_image` | Insert an image into the document at a specific position (from URL or local path) |
|
|
160
161
|
|
|
161
162
|
### Version Management
|
|
162
163
|
|
|
@@ -215,7 +216,7 @@ create_document({
|
|
|
215
216
|
- **`container`** (string) — container name within the workspace (e.g. "Chapters", "Notes", "References"). Auto-creates if not found. Requires `workspace`.
|
|
216
217
|
- Both are optional — omit for standalone docs outside any workspace.
|
|
217
218
|
|
|
218
|
-
This eliminates the need for separate `create_workspace`, `create_container`, and `
|
|
219
|
+
This eliminates the need for separate `create_workspace`, `create_container`, and `move_doc` calls when building up a workspace.
|
|
219
220
|
|
|
220
221
|
## Workflow
|
|
221
222
|
|
|
@@ -370,6 +371,86 @@ Users set their X handle by clicking the avatar circle in the compose area. The
|
|
|
370
371
|
5. **Respect pending changes.** If `pendingChanges > 0`, wait for the user
|
|
371
372
|
6. **Watch for the review signal.** When `userSignaledReview` is true, the user is asking for your input — reading status clears it (one-shot)
|
|
372
373
|
|
|
374
|
+
## Publish Platform (21 tools)
|
|
375
|
+
|
|
376
|
+
Requires authentication via `request_login_code` + `verify_login`. All publish tools are provided by the `@openwriter/plugin-publish` plugin.
|
|
377
|
+
|
|
378
|
+
### Authentication
|
|
379
|
+
|
|
380
|
+
| Tool | Description |
|
|
381
|
+
|------|-------------|
|
|
382
|
+
| `request_login_code` | Send a 6-digit login code to an email address (signup or key recovery) |
|
|
383
|
+
| `verify_login` | Verify the code → API key issued + auto-saved to plugin config |
|
|
384
|
+
|
|
385
|
+
```
|
|
386
|
+
1. request_login_code({ email: "user@example.com" }) → 6-digit code sent to email
|
|
387
|
+
2. User reads code from inbox (or agent reads via gmail skill)
|
|
388
|
+
3. verify_login({ email: "user@example.com", code: "123456" })
|
|
389
|
+
→ API key issued + auto-saved to plugin config
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
- **Agents with email access** (e.g. gmail skill) can fully automate this — zero user involvement
|
|
393
|
+
- **Key recovery:** Same flow. Old keys are automatically revoked when a new one is issued
|
|
394
|
+
- Codes expire in 10 minutes, max 3 attempts per code, rate-limited to 1 request per 60 seconds
|
|
395
|
+
|
|
396
|
+
### Custom Domains
|
|
397
|
+
|
|
398
|
+
| Tool | Description |
|
|
399
|
+
|------|-------------|
|
|
400
|
+
| `setup_custom_domain` | Configure a custom domain + from_email for newsletter sending |
|
|
401
|
+
| `check_domain_status` | Check DNS and sender verification status |
|
|
402
|
+
| `resend_domain_verification` | Re-send the SendGrid sender verification email |
|
|
403
|
+
|
|
404
|
+
**Setup flow:**
|
|
405
|
+
1. Call `setup_custom_domain` with domain + from_email
|
|
406
|
+
2. Cloudflare domains: DNS auto-added. Non-CF: show DNS records for manual setup
|
|
407
|
+
3. User checks email for SendGrid sender verification
|
|
408
|
+
4. Wait ~30-60s, call `check_domain_status` to confirm
|
|
409
|
+
5. Both `dns_verified` + `sender_verified` = domain ready
|
|
410
|
+
|
|
411
|
+
### Social Posting & Connections
|
|
412
|
+
|
|
413
|
+
| Tool | Description |
|
|
414
|
+
|------|-------------|
|
|
415
|
+
| `list_connections` | List connected social accounts (X, LinkedIn, etc.) |
|
|
416
|
+
| `post_to_x` | Post current document to X/Twitter |
|
|
417
|
+
| `post_to_linkedin` | Post current document to LinkedIn |
|
|
418
|
+
|
|
419
|
+
### Scheduling
|
|
420
|
+
|
|
421
|
+
| Tool | Description |
|
|
422
|
+
|------|-------------|
|
|
423
|
+
| `schedule_post` | Schedule a post for a specific time |
|
|
424
|
+
| `list_schedule` | List all scheduled posts |
|
|
425
|
+
| `manage_schedule` | Update or cancel a scheduled post |
|
|
426
|
+
| `list_slots` | List recurring time slots |
|
|
427
|
+
| `create_slot` | Create a recurring posting slot |
|
|
428
|
+
| `edit_slot` | Modify an existing slot |
|
|
429
|
+
| `delete_slot` | Remove a recurring slot |
|
|
430
|
+
|
|
431
|
+
### Newsletter
|
|
432
|
+
|
|
433
|
+
| Tool | Key Params | Description |
|
|
434
|
+
|------|-----------|-------------|
|
|
435
|
+
| `send_newsletter` | `subject?`, `format?`, `test_email?`, `subscriber_ids?`, `exclude_issue_id?` | Send current document as newsletter to all subscribers, a subset, or a test address |
|
|
436
|
+
| `list_subscribers` | `limit?`, `offset?` | List newsletter subscribers with IDs, emails, names |
|
|
437
|
+
| `add_subscriber` | `email`, `name?` | Add a single subscriber |
|
|
438
|
+
| `import_subscribers` | `file?`, `csv_text?` | Bulk import from CSV (auto-detects ConvertKit, Mailchimp, Substack, Beehiiv formats) |
|
|
439
|
+
| `list_newsletter_issues` | `limit?` | List past sends with open/click stats — returns issue IDs |
|
|
440
|
+
| `get_newsletter_analytics` | `issue_id` | Detailed drill-down: delivery stats, per-subscriber events, recipient list |
|
|
441
|
+
|
|
442
|
+
**Subscriber selection** — `send_newsletter` supports targeting:
|
|
443
|
+
- **All subscribers** (default) — omit both params
|
|
444
|
+
- **Specific subscribers** — pass `subscriber_ids: ["id1", "id2"]` (use `list_subscribers` for IDs)
|
|
445
|
+
- **Send to remaining** — pass `exclude_issue_id: "..."` to send to everyone who did NOT receive that issue (use `list_newsletter_issues` for issue IDs)
|
|
446
|
+
|
|
447
|
+
**Analytics workflow:**
|
|
448
|
+
```
|
|
449
|
+
1. list_newsletter_issues() → see past sends with open/click counts
|
|
450
|
+
2. get_newsletter_analytics({ issue_id }) → drill into a specific send
|
|
451
|
+
→ returns: stats (delivered, opens, clicks, bounces), per-subscriber events, recipient list
|
|
452
|
+
```
|
|
453
|
+
|
|
373
454
|
## Troubleshooting
|
|
374
455
|
|
|
375
456
|
**MCP tools not available** — The OpenWriter MCP server isn't configured yet. Follow the [setup instructions](#mcp-tools-are-not-available-skill-first-install) above. After adding the MCP config, the user must restart their Claude Code session.
|