openwriter 0.5.4 → 0.6.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.
@@ -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 { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
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
- const EXTERNAL_DOCS_FILE = join(DATA_DIR, 'external-docs.json');
30
+ function getExternalDocsFile() { return join(getDataDir(), 'external-docs.json'); }
31
31
  const externalDocs = new Set();
32
32
  function persistExternalDocs() {
33
33
  try {
34
- atomicWriteFileSync(EXTERNAL_DOCS_FILE, JSON.stringify([...externalDocs]));
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(EXTERNAL_DOCS_FILE)) {
41
- const paths = JSON.parse(readFileSync(EXTERNAL_DOCS_FILE, 'utf-8'));
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 (const node of nodes) {
132
- if (node.attrs?.id && ids.includes(node.attrs.id)) {
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: tweetContext / articleContext ↔ "x" + mode tag
150
- for (const key of ['tweetContext', 'articleContext']) {
151
- if (key in updates) {
152
- const filename = state.filePath
153
- ? (isExternalDoc(state.filePath) ? state.filePath : state.filePath.split(/[/\\]/).pop() || '')
154
- : '';
155
- if (filename) {
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 that the browser doesn't track in its document model.
191
- // Browser manages pending state as decorations, so its doc-updates lack pendingStatus.
192
- // Without this, browser overwrites server state and pending info is lost on next save.
193
- if (hasPendingChanges()) {
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 pendingStatus, pendingOriginalContent, and pendingTextEdits.
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
- pendingMap.set(node.attrs.id, {
212
- status: node.attrs.pendingStatus,
213
- original: node.attrs.pendingOriginalContent,
214
- textEdits: node.attrs.pendingTextEdits,
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.pendingStatus = p.status;
230
- if (p.original)
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 = 1500; // Block browser doc-updates for 1.5s after agent write
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(DATA_DIR).filter((f) => f.endsWith('.md'));
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(DATA_DIR, f), 'utf-8');
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(DATA_DIR, filename);
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(DATA_DIR)
829
+ const files = readdirSync(getDataDir())
746
830
  .filter((f) => f.endsWith('.md'))
747
831
  .map((f) => {
748
- const fullPath = join(DATA_DIR, f);
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(DATA_DIR).filter((f) => f.endsWith('.sw.json'));
882
+ const jsonFiles = readdirSync(getDataDir()).filter((f) => f.endsWith('.sw.json'));
799
883
  for (const f of jsonFiles) {
800
- const jsonPath = join(DATA_DIR, f);
884
+ const jsonPath = join(getDataDir(), f);
801
885
  const mdName = f.replace(/\.sw\.json$/, '.md');
802
- const mdPath = join(DATA_DIR, mdName);
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(DATA_DIR, '_workspaces');
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(DATA_DIR).filter((f) => f.startsWith(TEMP_PREFIX) && f.endsWith('.md'));
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(DATA_DIR, f);
963
+ const fullPath = join(getDataDir(), f);
880
964
  try {
881
965
  const raw = readFileSync(fullPath, 'utf-8');
882
966
  const parsed = markdownToTiptap(raw);
@@ -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 { VERSIONS_DIR } from './helpers.js';
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(VERSIONS_DIR, docId);
45
+ return join(getVersionsDir(), docId);
42
46
  }
43
47
  function ensureDocDir(docId) {
44
48
  const dir = docDir(docId);
@@ -22,6 +22,20 @@ export function createWorkspaceRouter(b) {
22
22
  res.status(400).json({ error: err.message });
23
23
  }
24
24
  });
25
+ // Static paths before parameterized — otherwise :filename captures "reorder"
26
+ router.put('/api/workspaces/reorder', (req, res) => {
27
+ try {
28
+ const { order } = req.body;
29
+ if (!Array.isArray(order))
30
+ return res.status(400).json({ error: 'order must be an array' });
31
+ reorderWorkspaces(order);
32
+ b.broadcastWorkspacesChanged();
33
+ res.json({ success: true });
34
+ }
35
+ catch (err) {
36
+ res.status(400).json({ error: err.message });
37
+ }
38
+ });
25
39
  router.get('/api/workspaces/:filename', (req, res) => {
26
40
  try {
27
41
  res.json(getWorkspace(req.params.filename));
@@ -50,19 +64,6 @@ export function createWorkspaceRouter(b) {
50
64
  res.status(400).json({ error: err.message });
51
65
  }
52
66
  });
53
- router.put('/api/workspaces/reorder', (req, res) => {
54
- try {
55
- const { order } = req.body;
56
- if (!Array.isArray(order))
57
- return res.status(400).json({ error: 'order must be an array' });
58
- reorderWorkspaces(order);
59
- b.broadcastWorkspacesChanged();
60
- res.json({ success: true });
61
- }
62
- catch (err) {
63
- res.status(400).json({ error: err.message });
64
- }
65
- });
66
67
  // Doc operations
67
68
  router.post('/api/workspaces/:filename/docs', (req, res) => {
68
69
  try {
@@ -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 { WORKSPACES_DIR, ensureWorkspacesDir, sanitizeFilename, resolveDocPath, isExternalDoc } from './helpers.js';
11
+ import { getWorkspacesDir, ensureWorkspacesDir, sanitizeFilename, resolveDocPath, isExternalDoc } from './helpers.js';
12
12
  import { markdownToTiptap, tiptapToMarkdown } from './markdown.js';
13
- const ORDER_FILE = join(WORKSPACES_DIR, '_order.json');
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(WORKSPACES_DIR, filename);
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(ORDER_FILE))
78
+ if (!existsSync(getOrderFile()))
79
79
  return [];
80
- return JSON.parse(readFileSync(ORDER_FILE, 'utf-8'));
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(ORDER_FILE, JSON.stringify(order, null, 2), 'utf-8');
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(WORKSPACES_DIR).filter((f) => f.endsWith('.json') && f !== '_order.json');
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.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/skill/SKILL.md CHANGED
@@ -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. 31 MCP tools for document editing, multi-doc workspaces, and
7
- organization. Tweet compose mode for drafting replies/QTs with pixel-accurate
8
- X/Twitter UI. Plain .md files on disk no database, no lock-in.
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.1.0"
18
+ version: "0.2.0"
18
19
  repository: https://github.com/travsteward/openwriter
19
20
  license: MIT
20
21
  ---
@@ -56,7 +57,7 @@ npm install -g openwriter
56
57
  claude mcp add -s user openwriter -- openwriter --no-open
57
58
  ```
58
59
 
59
- Then restart the Claude Code session. The MCP tools become available on next launch.
60
+ Then restart the Claude Code session. The 57 MCP tools become available on next launch.
60
61
 
61
62
  **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:
62
63
 
@@ -87,7 +88,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
87
88
  - All doc-targeting tools take `docId` as their parameter (not filename)
88
89
  - Two documents can have the same title — the docId disambiguates
89
90
 
90
- ## MCP Tools Reference (32 tools)
91
+ ## MCP Tools Reference (36 core + 21 publish platform)
91
92
 
92
93
  ### Document Operations
93
94
 
@@ -110,6 +111,8 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
110
111
  | `create_document` | `title?`, ... | Create a new empty document — response includes docId |
111
112
  | `open_file` | `path` | Open an existing .md file from any location on disk |
112
113
  | `delete_document` | `docId` | Delete a document file (moves to OS trash, recoverable) |
114
+ | `archive_document` | `docId` | Archive a document (hides from sidebar, keeps on disk) |
115
+ | `unarchive_document` | `docId` | Restore an archived document back to the sidebar |
113
116
 
114
117
  ### Import
115
118
 
@@ -132,11 +135,11 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
132
135
 
133
136
  | Tool | Description |
134
137
  |------|-------------|
135
- | `add_doc` | Add a document to a workspace (optional container placement) |
136
138
  | `create_container` | Create a folder inside a workspace (max depth: 3) |
137
- | `tag_doc` | Add a tag to a document (stored in doc frontmatter) |
138
- | `untag_doc` | Remove a tag from a document (stored in doc frontmatter) |
139
- | `move_doc` | Move a document to a different container or root level |
139
+ | `delete_container` | Delete a container from a workspace (doc files stay on disk) |
140
+ | `tag_doc` | Add a tag to a document by docId (stored in doc frontmatter) |
141
+ | `untag_doc` | Remove a tag from a document by docId |
142
+ | `move_doc` | Add a doc to a workspace, or move it within the workspace (by docId) |
140
143
  | `rename_item` | Rename a workspace, container, or document (type: workspace/container/document) |
141
144
 
142
145
  ### Agent Marks
@@ -157,6 +160,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
157
160
  | Tool | Description |
158
161
  |------|-------------|
159
162
  | `generate_image` | Generate an image via Gemini Nano Banana 2 — optionally set as article cover (requires GEMINI_API_KEY) |
163
+ | `insert_image` | Insert an image into the document at a specific position (from URL or local path) |
160
164
 
161
165
  ### Version Management
162
166
 
@@ -215,7 +219,7 @@ create_document({
215
219
  - **`container`** (string) — container name within the workspace (e.g. "Chapters", "Notes", "References"). Auto-creates if not found. Requires `workspace`.
216
220
  - Both are optional — omit for standalone docs outside any workspace.
217
221
 
218
- This eliminates the need for separate `create_workspace`, `create_container`, and `add_doc` calls when building up a workspace.
222
+ This eliminates the need for separate `create_workspace`, `create_container`, and `move_doc` calls when building up a workspace.
219
223
 
220
224
  ## Workflow
221
225
 
@@ -370,6 +374,86 @@ Users set their X handle by clicking the avatar circle in the compose area. The
370
374
  5. **Respect pending changes.** If `pendingChanges > 0`, wait for the user
371
375
  6. **Watch for the review signal.** When `userSignaledReview` is true, the user is asking for your input — reading status clears it (one-shot)
372
376
 
377
+ ## Publish Platform (21 tools)
378
+
379
+ Requires authentication via `request_login_code` + `verify_login`. All publish tools are provided by the `@openwriter/plugin-publish` plugin.
380
+
381
+ ### Authentication
382
+
383
+ | Tool | Description |
384
+ |------|-------------|
385
+ | `request_login_code` | Send a 6-digit login code to an email address (signup or key recovery) |
386
+ | `verify_login` | Verify the code → API key issued + auto-saved to plugin config |
387
+
388
+ ```
389
+ 1. request_login_code({ email: "user@example.com" }) → 6-digit code sent to email
390
+ 2. User reads code from inbox (or agent reads via gmail skill)
391
+ 3. verify_login({ email: "user@example.com", code: "123456" })
392
+ → API key issued + auto-saved to plugin config
393
+ ```
394
+
395
+ - **Agents with email access** (e.g. gmail skill) can fully automate this — zero user involvement
396
+ - **Key recovery:** Same flow. Old keys are automatically revoked when a new one is issued
397
+ - Codes expire in 10 minutes, max 3 attempts per code, rate-limited to 1 request per 60 seconds
398
+
399
+ ### Custom Domains
400
+
401
+ | Tool | Description |
402
+ |------|-------------|
403
+ | `setup_custom_domain` | Configure a custom domain + from_email for newsletter sending |
404
+ | `check_domain_status` | Check DNS and sender verification status |
405
+ | `resend_domain_verification` | Re-send the SendGrid sender verification email |
406
+
407
+ **Setup flow:**
408
+ 1. Call `setup_custom_domain` with domain + from_email
409
+ 2. Cloudflare domains: DNS auto-added. Non-CF: show DNS records for manual setup
410
+ 3. User checks email for SendGrid sender verification
411
+ 4. Wait ~30-60s, call `check_domain_status` to confirm
412
+ 5. Both `dns_verified` + `sender_verified` = domain ready
413
+
414
+ ### Social Posting & Connections
415
+
416
+ | Tool | Description |
417
+ |------|-------------|
418
+ | `list_connections` | List connected social accounts (X, LinkedIn, etc.) |
419
+ | `post_to_x` | Post current document to X/Twitter |
420
+ | `post_to_linkedin` | Post current document to LinkedIn |
421
+
422
+ ### Scheduling
423
+
424
+ | Tool | Description |
425
+ |------|-------------|
426
+ | `schedule_post` | Schedule a post for a specific time |
427
+ | `list_schedule` | List all scheduled posts |
428
+ | `manage_schedule` | Update or cancel a scheduled post |
429
+ | `list_slots` | List recurring time slots |
430
+ | `create_slot` | Create a recurring posting slot |
431
+ | `edit_slot` | Modify an existing slot |
432
+ | `delete_slot` | Remove a recurring slot |
433
+
434
+ ### Newsletter
435
+
436
+ | Tool | Key Params | Description |
437
+ |------|-----------|-------------|
438
+ | `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 |
439
+ | `list_subscribers` | `limit?`, `offset?` | List newsletter subscribers with IDs, emails, names |
440
+ | `add_subscriber` | `email`, `name?` | Add a single subscriber |
441
+ | `import_subscribers` | `file?`, `csv_text?` | Bulk import from CSV (auto-detects ConvertKit, Mailchimp, Substack, Beehiiv formats) |
442
+ | `list_newsletter_issues` | `limit?` | List past sends with open/click stats — returns issue IDs |
443
+ | `get_newsletter_analytics` | `issue_id` | Detailed drill-down: delivery stats, per-subscriber events, recipient list |
444
+
445
+ **Subscriber selection** — `send_newsletter` supports targeting:
446
+ - **All subscribers** (default) — omit both params
447
+ - **Specific subscribers** — pass `subscriber_ids: ["id1", "id2"]` (use `list_subscribers` for IDs)
448
+ - **Send to remaining** — pass `exclude_issue_id: "..."` to send to everyone who did NOT receive that issue (use `list_newsletter_issues` for issue IDs)
449
+
450
+ **Analytics workflow:**
451
+ ```
452
+ 1. list_newsletter_issues() → see past sends with open/click counts
453
+ 2. get_newsletter_analytics({ issue_id }) → drill into a specific send
454
+ → returns: stats (delivered, opens, clicks, bounces), per-subscriber events, recipient list
455
+ ```
456
+
373
457
  ## Troubleshooting
374
458
 
375
459
  **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.