openwriter 0.4.0 → 0.5.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.
@@ -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 } from './helpers.js';
11
+ import { DATA_DIR, 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',
@@ -31,7 +31,7 @@ const EXTERNAL_DOCS_FILE = join(DATA_DIR, 'external-docs.json');
31
31
  const externalDocs = new Set();
32
32
  function persistExternalDocs() {
33
33
  try {
34
- writeFileSync(EXTERNAL_DOCS_FILE, JSON.stringify([...externalDocs]), 'utf-8');
34
+ atomicWriteFileSync(EXTERNAL_DOCS_FILE, JSON.stringify([...externalDocs]));
35
35
  }
36
36
  catch { /* best-effort */ }
37
37
  }
@@ -172,6 +172,15 @@ export function getStatus() {
172
172
  // SETTERS
173
173
  // ============================================================================
174
174
  export function updateDocument(doc) {
175
+ // Safety: reject dramatically smaller documents (same logic as destructive save check).
176
+ // Prevents stale browser tabs from overwriting the correct in-memory state with
177
+ // corrupted content (e.g. tweet compose view sending 4-node doc vs 40-node original).
178
+ const currentNodes = state.document?.content?.length ?? 0;
179
+ const incomingNodes = doc?.content?.length ?? 0;
180
+ if (currentNodes > 5 && incomingNodes < currentNodes * 0.3) {
181
+ console.error(`[State] BLOCKED destructive updateDocument: ${incomingNodes} nodes would replace ${currentNodes} nodes`);
182
+ return;
183
+ }
175
184
  // Preserve pending attrs that the browser doesn't track in its document model.
176
185
  // Browser manages pending state as decorations, so its doc-updates lack pendingStatus.
177
186
  // Without this, browser overwrites server state and pending info is lost on next save.
@@ -291,13 +300,11 @@ export function onChanges(listener) {
291
300
  // ============================================================================
292
301
  // generateNodeId imported from helpers.ts
293
302
  /**
294
- * Find a node by ID in the document tree.
295
- * Returns the parent array and index for in-place mutation.
303
+ * Find a node by ID in any document tree.
304
+ * topLevel is used to resolve the "end" sentinel.
296
305
  */
297
- function findNodeInDoc(nodes, id) {
298
- // Special sentinel: "end" resolves to the last top-level node in the document
306
+ function findNode(nodes, id, topLevel) {
299
307
  if (id === 'end') {
300
- const topLevel = state.document.content;
301
308
  if (topLevel && topLevel.length > 0) {
302
309
  return { parent: topLevel, index: topLevel.length - 1 };
303
310
  }
@@ -308,36 +315,43 @@ function findNodeInDoc(nodes, id) {
308
315
  return { parent: nodes, index: i };
309
316
  }
310
317
  if (nodes[i].content && Array.isArray(nodes[i].content)) {
311
- const result = findNodeInDoc(nodes[i].content, id);
318
+ const result = findNode(nodes[i].content, id, topLevel);
312
319
  if (result)
313
320
  return result;
314
321
  }
315
322
  }
316
323
  return null;
317
324
  }
325
+ /** Find a node in the active document. */
326
+ function findNodeInDoc(nodes, id) {
327
+ return findNode(nodes, id, state.document.content);
328
+ }
318
329
  /**
319
- * Apply changes to server-side document and return processed changes
320
- * with server-assigned IDs for broadcast to browsers.
330
+ * Core change application logic operates on any document object.
331
+ * Mutates doc in place and returns processed changes with server-assigned IDs.
321
332
  */
322
- function applyChangesToDocument(changes) {
333
+ function applyChangesToDoc(doc, changes) {
323
334
  const processed = [];
324
335
  for (const change of changes) {
325
336
  if (change.operation === 'rewrite' && change.nodeId && change.content) {
326
- const found = findNodeInDoc(state.document.content, change.nodeId);
337
+ const found = findNode(doc.content, change.nodeId, doc.content);
327
338
  if (!found)
328
339
  continue;
329
340
  const contentArray = Array.isArray(change.content) ? change.content : [change.content];
330
341
  const originalNode = structuredClone(found.parent[found.index]);
342
+ // Empty node rewrite → treat as insert (green, not blue)
343
+ const originalText = extractText(originalNode.content || []);
344
+ const isEmptyNode = !originalText.trim();
331
345
  // Only store original on first rewrite (preserve baseline for reject)
332
346
  const existingOriginal = found.parent[found.index].attrs?.pendingOriginalContent;
333
- // First node replaces the target (rewrite)
347
+ // First node replaces the target (rewrite or insert if empty)
334
348
  const firstNode = {
335
349
  ...contentArray[0],
336
350
  attrs: {
337
351
  ...contentArray[0].attrs,
338
352
  id: change.nodeId,
339
- pendingStatus: 'rewrite',
340
- pendingOriginalContent: existingOriginal || originalNode,
353
+ pendingStatus: isEmptyNode ? 'insert' : 'rewrite',
354
+ ...(isEmptyNode ? {} : { pendingOriginalContent: existingOriginal || originalNode }),
341
355
  },
342
356
  };
343
357
  // Additional nodes get inserted after as pending inserts
@@ -367,17 +381,20 @@ function applyChangesToDocument(changes) {
367
381
  }));
368
382
  // Mark leaf blocks as pending (not containers) for correct serialization
369
383
  markLeafBlocksAsPending(contentWithIds, 'insert');
384
+ let resolvedAfterId;
370
385
  if (change.nodeId && !change.afterNodeId) {
371
386
  // Replace empty node
372
- const found = findNodeInDoc(state.document.content, change.nodeId);
387
+ const found = findNode(doc.content, change.nodeId, doc.content);
373
388
  if (!found)
374
389
  continue;
375
390
  found.parent.splice(found.index, 1, ...contentWithIds);
376
391
  }
377
392
  else if (change.afterNodeId) {
378
- const found = findNodeInDoc(state.document.content, change.afterNodeId);
393
+ const found = findNode(doc.content, change.afterNodeId, doc.content);
379
394
  if (!found)
380
395
  continue;
396
+ // Resolve "end" sentinel to actual node ID so browser can find it
397
+ resolvedAfterId = found.parent[found.index]?.attrs?.id;
381
398
  found.parent.splice(found.index + 1, 0, ...contentWithIds);
382
399
  }
383
400
  else {
@@ -386,11 +403,13 @@ function applyChangesToDocument(changes) {
386
403
  // Broadcast with server-assigned IDs so browser uses the same IDs
387
404
  processed.push({
388
405
  ...change,
406
+ // Replace "end" with the resolved node ID so browser can look it up
407
+ ...(resolvedAfterId && change.afterNodeId === 'end' ? { afterNodeId: resolvedAfterId } : {}),
389
408
  content: contentWithIds.length === 1 ? contentWithIds[0] : contentWithIds,
390
409
  });
391
410
  }
392
411
  else if (change.operation === 'delete' && change.nodeId) {
393
- const found = findNodeInDoc(state.document.content, change.nodeId);
412
+ const found = findNode(doc.content, change.nodeId, doc.content);
394
413
  if (!found)
395
414
  continue;
396
415
  found.parent[found.index] = {
@@ -403,6 +422,11 @@ function applyChangesToDocument(changes) {
403
422
  processed.push(change);
404
423
  }
405
424
  }
425
+ return processed;
426
+ }
427
+ /** Apply changes to the active document singleton. */
428
+ function applyChangesToDocument(changes) {
429
+ const processed = applyChangesToDoc(state.document, changes);
406
430
  if (processed.length > 0) {
407
431
  state.lastModified = new Date();
408
432
  }
@@ -471,6 +495,15 @@ export function updatePendingCacheForActiveDoc() {
471
495
  export function removePendingCacheEntry(filename) {
472
496
  pendingDocCache.delete(filename);
473
497
  }
498
+ /** Set the pending cache entry for a specific filename (for non-active doc population). */
499
+ export function setPendingCacheEntry(filename, count) {
500
+ if (count > 0) {
501
+ pendingDocCache.set(filename, count);
502
+ }
503
+ else {
504
+ pendingDocCache.delete(filename);
505
+ }
506
+ }
474
507
  /** Populate the pending cache from a full disk scan. Called once on startup. */
475
508
  function populatePendingCache() {
476
509
  pendingDocCache.clear();
@@ -502,6 +535,67 @@ function populatePendingCache() {
502
535
  catch { /* skip unreadable files */ }
503
536
  }
504
537
  }
538
+ const docCache = new Map(); // key = filePath
539
+ /** Cache the active document's full state, keyed by filePath. Call after save(). */
540
+ export function cacheActiveDocument() {
541
+ if (!state.filePath)
542
+ return;
543
+ let fileMtime = 0;
544
+ try {
545
+ fileMtime = statSync(state.filePath).mtimeMs;
546
+ }
547
+ catch { /* file may not exist yet */ }
548
+ docCache.set(state.filePath, {
549
+ document: structuredClone(state.document),
550
+ metadata: structuredClone(state.metadata),
551
+ title: state.title,
552
+ isTemp: state.isTemp,
553
+ lastModified: state.lastModified,
554
+ docId: state.docId,
555
+ fileMtime,
556
+ });
557
+ }
558
+ /** Get a cached document if the file hasn't been modified externally. Returns null on miss or stale. */
559
+ export function getCachedDocument(filePath) {
560
+ const cached = docCache.get(filePath);
561
+ if (!cached)
562
+ return null;
563
+ try {
564
+ const currentMtime = statSync(filePath).mtimeMs;
565
+ if (currentMtime !== cached.fileMtime) {
566
+ // File changed on disk — invalidate cache
567
+ docCache.delete(filePath);
568
+ return null;
569
+ }
570
+ }
571
+ catch {
572
+ // File doesn't exist or can't be read — invalidate
573
+ docCache.delete(filePath);
574
+ return null;
575
+ }
576
+ return cached;
577
+ }
578
+ /** Remove a specific file from the document cache. */
579
+ export function invalidateDocCache(filePath) {
580
+ docCache.delete(filePath);
581
+ }
582
+ /** Update the cache entry for a file after writing changes (without cloning the active state). */
583
+ function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
584
+ let fileMtime = 0;
585
+ try {
586
+ fileMtime = statSync(filePath).mtimeMs;
587
+ }
588
+ catch { /* best-effort */ }
589
+ docCache.set(filePath, {
590
+ document: structuredClone(doc),
591
+ metadata: structuredClone(metadata),
592
+ title,
593
+ isTemp,
594
+ lastModified: new Date(),
595
+ docId,
596
+ fileMtime,
597
+ });
598
+ }
505
599
  // ============================================================================
506
600
  // PENDING DOCUMENT STORE OPERATIONS
507
601
  // ============================================================================
@@ -567,10 +661,20 @@ export function markAllNodesAsPending(doc, status) {
567
661
  export function getPendingDocInfo() {
568
662
  const filenames = [];
569
663
  const counts = {};
664
+ const stale = [];
570
665
  for (const [filename, count] of pendingDocCache) {
666
+ // Validate file still exists on disk (prunes ghost entries after server restart)
667
+ const filePath = isExternalDoc(filename) ? filename : join(DATA_DIR, filename);
668
+ if (!existsSync(filePath)) {
669
+ stale.push(filename);
670
+ continue;
671
+ }
571
672
  filenames.push(filename);
572
673
  counts[filename] = count;
573
674
  }
675
+ // Clean up stale entries
676
+ for (const f of stale)
677
+ pendingDocCache.delete(f);
574
678
  return { filenames, counts };
575
679
  }
576
680
  // ============================================================================
@@ -601,7 +705,7 @@ function writeToDisk() {
601
705
  catch { /* stat failed, proceed with save */ }
602
706
  }
603
707
  }
604
- writeFileSync(state.filePath, markdown, 'utf-8');
708
+ atomicWriteFileSync(state.filePath, markdown);
605
709
  // Best-effort version snapshot — never blocks saves
606
710
  try {
607
711
  snapshotIfNeeded(state.docId, state.filePath);
@@ -661,7 +765,7 @@ export function load() {
661
765
  state.docId = ensureDocId(state.metadata);
662
766
  if (!hadDocId) {
663
767
  const md = tiptapToMarkdown(state.document, state.title, state.metadata);
664
- writeFileSync(state.filePath, md, 'utf-8');
768
+ atomicWriteFileSync(state.filePath, md);
665
769
  }
666
770
  break;
667
771
  }
@@ -770,7 +874,11 @@ function cleanupEmptyTempFiles() {
770
874
  try {
771
875
  const raw = readFileSync(fullPath, 'utf-8');
772
876
  const parsed = markdownToTiptap(raw);
773
- if (isDocEmpty(parsed.document)) {
877
+ // Keep temp files that have meaningful metadata (templates, pending changes, tags)
878
+ const meta = parsed.metadata || {};
879
+ const hasMetadata = meta.tweetContext || meta.articleContext || meta.pending || meta.agentCreated
880
+ || (Array.isArray(meta.tags) && meta.tags.length > 0);
881
+ if (isDocEmpty(parsed.document) && !hasMetadata) {
774
882
  unlinkSync(fullPath);
775
883
  }
776
884
  }
@@ -841,7 +949,7 @@ export function addDocTag(filename, tag) {
841
949
  tags.push(tag);
842
950
  parsed.metadata.tags = tags;
843
951
  const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
844
- writeFileSync(targetPath, markdown, 'utf-8');
952
+ atomicWriteFileSync(targetPath, markdown);
845
953
  }
846
954
  }
847
955
  catch { /* best-effort */ }
@@ -874,7 +982,7 @@ export function removeDocTag(filename, tag) {
874
982
  tags.splice(idx, 1);
875
983
  parsed.metadata.tags = tags.length > 0 ? tags : undefined;
876
984
  const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
877
- writeFileSync(targetPath, markdown, 'utf-8');
985
+ atomicWriteFileSync(targetPath, markdown);
878
986
  }
879
987
  }
880
988
  catch { /* best-effort */ }
@@ -899,7 +1007,7 @@ export function saveDocToFile(filename, doc) {
899
1007
  transferPendingAttrs(parsed.document, doc);
900
1008
  }
901
1009
  const markdown = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
902
- writeFileSync(targetPath, markdown, 'utf-8');
1010
+ atomicWriteFileSync(targetPath, markdown);
903
1011
  }
904
1012
  catch { /* best-effort */ }
905
1013
  }
@@ -933,8 +1041,144 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
933
1041
  delete parsed.metadata.agentCreated;
934
1042
  }
935
1043
  const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
936
- writeFileSync(targetPath, markdown, 'utf-8');
1044
+ atomicWriteFileSync(targetPath, markdown);
937
1045
  removePendingCacheEntry(filename);
938
1046
  }
939
1047
  catch { /* best-effort */ }
940
1048
  }
1049
+ /**
1050
+ * Populate a non-active document file with content.
1051
+ * Writes directly to disk without touching the active singleton.
1052
+ * Returns { title, wordCount, pendingCount } for the response message.
1053
+ */
1054
+ /** Count pending nodes in a document tree. */
1055
+ function countPending(nodes) {
1056
+ let count = 0;
1057
+ if (!nodes)
1058
+ return 0;
1059
+ for (const node of nodes) {
1060
+ if (node.attrs?.pendingStatus)
1061
+ count++;
1062
+ if (node.content)
1063
+ count += countPending(node.content);
1064
+ }
1065
+ return count;
1066
+ }
1067
+ /** Write a mutated doc back to disk and update the pending cache. */
1068
+ function flushDocToFile(filename, doc, title, metadata) {
1069
+ const targetPath = resolveDocPath(filename);
1070
+ const markdown = tiptapToMarkdown(doc, title, metadata);
1071
+ atomicWriteFileSync(targetPath, markdown);
1072
+ setPendingCacheEntry(filename, countPending(doc.content));
1073
+ }
1074
+ export function populateDocumentFile(filename, doc) {
1075
+ const targetPath = resolveDocPath(filename);
1076
+ const raw = readFileSync(targetPath, 'utf-8');
1077
+ const parsed = markdownToTiptap(raw);
1078
+ markAllNodesAsPending(doc, 'insert');
1079
+ flushDocToFile(filename, doc, parsed.title, parsed.metadata);
1080
+ const pendingCount = countPending(doc.content);
1081
+ const text = extractText(doc.content);
1082
+ const wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
1083
+ return { title: parsed.title, wordCount, pendingCount };
1084
+ }
1085
+ /**
1086
+ * Apply node changes to a non-active document file on disk.
1087
+ * Same logic as applyChanges but without touching the active singleton or broadcasting to browser.
1088
+ */
1089
+ export function applyChangesToFile(filename, changes) {
1090
+ const targetPath = resolveDocPath(filename);
1091
+ // Try cache first — preserves stable node IDs
1092
+ const cached = getCachedDocument(targetPath);
1093
+ let doc;
1094
+ let title;
1095
+ let metadata;
1096
+ let docId;
1097
+ let isTemp;
1098
+ if (cached) {
1099
+ doc = structuredClone(cached.document);
1100
+ title = cached.title;
1101
+ metadata = cached.metadata;
1102
+ docId = cached.docId;
1103
+ isTemp = cached.isTemp;
1104
+ }
1105
+ else {
1106
+ const raw = readFileSync(targetPath, 'utf-8');
1107
+ const parsed = markdownToTiptap(raw);
1108
+ doc = parsed.document;
1109
+ title = parsed.title;
1110
+ metadata = parsed.metadata;
1111
+ docId = metadata.docId || '';
1112
+ isTemp = false;
1113
+ }
1114
+ const processed = applyChangesToDoc(doc, changes);
1115
+ if (processed.length > 0) {
1116
+ flushDocToFile(filename, doc, title, metadata);
1117
+ updateCacheEntry(targetPath, doc, title, metadata, isTemp, docId);
1118
+ }
1119
+ // Find the last created node ID for chaining inserts
1120
+ let lastNodeId = null;
1121
+ for (let i = processed.length - 1; i >= 0; i--) {
1122
+ const change = processed[i];
1123
+ if (change.content) {
1124
+ const contentArr = Array.isArray(change.content) ? change.content : [change.content];
1125
+ const lastNode = contentArr[contentArr.length - 1];
1126
+ if (lastNode?.attrs?.id) {
1127
+ lastNodeId = lastNode.attrs.id;
1128
+ break;
1129
+ }
1130
+ }
1131
+ }
1132
+ return { count: processed.length, lastNodeId };
1133
+ }
1134
+ /**
1135
+ * Apply fine-grained text edits to a node in a non-active document file on disk.
1136
+ */
1137
+ export function applyTextEditsToFile(filename, nodeId, edits) {
1138
+ const targetPath = resolveDocPath(filename);
1139
+ // Try cache first — preserves stable node IDs
1140
+ const cached = getCachedDocument(targetPath);
1141
+ let doc;
1142
+ let title;
1143
+ let metadata;
1144
+ let docId;
1145
+ let isTemp;
1146
+ if (cached) {
1147
+ doc = structuredClone(cached.document);
1148
+ title = cached.title;
1149
+ metadata = cached.metadata;
1150
+ docId = cached.docId;
1151
+ isTemp = cached.isTemp;
1152
+ }
1153
+ else {
1154
+ const raw = readFileSync(targetPath, 'utf-8');
1155
+ const parsed = markdownToTiptap(raw);
1156
+ doc = parsed.document;
1157
+ title = parsed.title;
1158
+ metadata = parsed.metadata;
1159
+ docId = metadata.docId || '';
1160
+ isTemp = false;
1161
+ }
1162
+ const found = findNode(doc.content, nodeId, doc.content);
1163
+ if (!found)
1164
+ return { success: false, error: `Node ${nodeId} not found` };
1165
+ const originalNode = found.parent[found.index];
1166
+ const result = applyTextEditsToNode(originalNode, edits);
1167
+ if (!result)
1168
+ return { success: false, error: 'No edits matched' };
1169
+ result.node.attrs = {
1170
+ ...result.node.attrs,
1171
+ pendingTextEdits: result.textEdits,
1172
+ };
1173
+ // Apply as a rewrite to the doc
1174
+ const processed = applyChangesToDoc(doc, [{
1175
+ operation: 'rewrite',
1176
+ nodeId,
1177
+ content: result.node,
1178
+ }]);
1179
+ if (processed.length > 0) {
1180
+ flushDocToFile(filename, doc, title, metadata);
1181
+ updateCacheEntry(targetPath, doc, title, metadata, isTemp, docId);
1182
+ }
1183
+ return { success: true };
1184
+ }
@@ -3,7 +3,7 @@
3
3
  * Mounted in index.ts to keep the main file lean.
4
4
  */
5
5
  import { Router } from 'express';
6
- import { listWorkspaces, getWorkspace, createWorkspace, deleteWorkspace, reorderWorkspaces, addDoc, removeDoc, moveDoc, reorderDoc, addContainerToWorkspace, removeContainer, renameContainer, reorderContainer, } from './workspaces.js';
6
+ import { listWorkspaces, getWorkspace, createWorkspace, deleteWorkspace, reorderWorkspaces, addDoc, removeDoc, moveDoc, reorderDoc, addContainerToWorkspace, removeContainer, renameContainer, renameWorkspace, reorderContainer, } from './workspaces.js';
7
7
  export function createWorkspaceRouter(b) {
8
8
  const router = Router();
9
9
  router.get('/api/workspaces', (_req, res) => {
@@ -30,6 +30,16 @@ export function createWorkspaceRouter(b) {
30
30
  res.status(404).json({ error: err.message });
31
31
  }
32
32
  });
33
+ router.put('/api/workspaces/:filename', (req, res) => {
34
+ try {
35
+ const ws = renameWorkspace(req.params.filename, req.body.title);
36
+ b.broadcastWorkspacesChanged();
37
+ res.json(ws);
38
+ }
39
+ catch (err) {
40
+ res.status(400).json({ error: err.message });
41
+ }
42
+ });
33
43
  router.delete('/api/workspaces/:filename', async (req, res) => {
34
44
  try {
35
45
  await deleteWorkspace(req.params.filename);
@@ -234,6 +234,12 @@ export function renameContainer(wsFile, containerId, name) {
234
234
  writeWorkspace(wsFile, ws);
235
235
  return ws;
236
236
  }
237
+ export function renameWorkspace(wsFile, newTitle) {
238
+ const ws = getWorkspace(wsFile);
239
+ ws.title = newTitle;
240
+ writeWorkspace(wsFile, ws);
241
+ return ws;
242
+ }
237
243
  export function reorderContainer(wsFile, containerId, afterIdentifier) {
238
244
  const ws = getWorkspace(wsFile);
239
245
  reorderNode(ws.root, containerId, afterIdentifier);
package/dist/server/ws.js CHANGED
@@ -27,7 +27,23 @@ function debouncedBroadcastDocumentsChanged() {
27
27
  }, 2100);
28
28
  }
29
29
  export function setupWebSocket(server) {
30
- const wss = new WebSocketServer({ server });
30
+ const wss = new WebSocketServer({
31
+ server,
32
+ verifyClient: ({ req }) => {
33
+ const origin = req.headers.origin;
34
+ // Allow connections with no origin (non-browser clients like MCP)
35
+ // and localhost origins only (blocks cross-site WebSocket hijacking)
36
+ if (!origin)
37
+ return true;
38
+ try {
39
+ const url = new URL(origin);
40
+ return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ },
46
+ });
31
47
  // Push agent changes to all browser clients
32
48
  onChanges((changes) => {
33
49
  const msg = JSON.stringify({ type: 'node-changes', changes });
@@ -126,20 +142,29 @@ export function setupWebSocket(server) {
126
142
  try {
127
143
  const tmpl = msg.template;
128
144
  const url = msg.url;
129
- // Create with no title → temp file path (avoids naming conflicts)
130
- const result = createDocument();
131
- // Set template-appropriate metadata
145
+ // Create named document (dedup handles collisions)
146
+ let title = 'Untitled';
147
+ if (tmpl === 'tweet')
148
+ title = 'Tweet';
149
+ else if (tmpl === 'reply')
150
+ title = 'Reply';
151
+ else if (tmpl === 'quote')
152
+ title = 'Quote Tweet';
153
+ else if (tmpl === 'article')
154
+ title = 'Article';
155
+ const result = createDocument(title);
156
+ // Set template-specific metadata
132
157
  if (tmpl === 'tweet') {
133
- setMetadata({ tweetContext: { mode: 'tweet' }, title: 'Tweet' });
158
+ setMetadata({ tweetContext: { mode: 'tweet' } });
134
159
  }
135
160
  else if (tmpl === 'reply') {
136
- setMetadata({ tweetContext: { url, mode: 'reply' }, title: 'Reply' });
161
+ setMetadata({ tweetContext: { url, mode: 'reply' } });
137
162
  }
138
163
  else if (tmpl === 'quote') {
139
- setMetadata({ tweetContext: { url, mode: 'quote' }, title: 'Quote Tweet' });
164
+ setMetadata({ tweetContext: { url, mode: 'quote' } });
140
165
  }
141
166
  else if (tmpl === 'article') {
142
- setMetadata({ articleContext: { active: true }, title: 'Article' });
167
+ setMetadata({ articleContext: { active: true } });
143
168
  }
144
169
  save();
145
170
  broadcastDocumentSwitched(result.document, getTitle(), result.filename, getMetadata());
@@ -186,6 +211,7 @@ export function setupWebSocket(server) {
186
211
  }
187
212
  stripPendingAttrs();
188
213
  save();
214
+ updatePendingCacheForActiveDoc(); // Sync cache after strip (prevents stale "has changes" indicator)
189
215
  }
190
216
  else {
191
217
  // Race path: resolved doc is NOT the active one (server switched away).
@@ -206,7 +232,8 @@ export function setupWebSocket(server) {
206
232
  });
207
233
  }
208
234
  export function broadcastDocumentSwitched(document, title, filename, metadata) {
209
- const msg = JSON.stringify({ type: 'document-switched', document, title, filename, docId: getDocId(), metadata: metadata ?? getMetadata() });
235
+ const resolvedMeta = metadata ?? getMetadata();
236
+ const msg = JSON.stringify({ type: 'document-switched', document, title, filename, docId: getDocId(), metadata: resolvedMeta });
210
237
  for (const ws of clients) {
211
238
  if (ws.readyState === WebSocket.OPEN) {
212
239
  ws.send(msg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.4.0",
3
+ "version": "0.5.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,7 +3,7 @@ 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. 30 MCP tools for document editing, multi-doc workspaces, and
6
+ in-browser. 31 MCP tools for document editing, multi-doc workspaces, and
7
7
  organization. Tweet compose mode for drafting replies/QTs with pixel-accurate
8
8
  X/Twitter UI. Plain .md files on disk — no database, no lock-in.
9
9
 
@@ -14,7 +14,7 @@ description: |
14
14
  Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
15
15
  metadata:
16
16
  author: travsteward
17
- version: "0.4.0"
17
+ version: "0.5.0"
18
18
  repository: https://github.com/travsteward/openwriter
19
19
  license: MIT
20
20
  ---
@@ -38,7 +38,7 @@ Skip to [Writing Strategy](#writing-strategy) below.
38
38
 
39
39
  ### MCP tools are NOT available (skill-first install)
40
40
 
41
- The user installed this skill from a directory but hasn't set up the MCP server yet. OpenWriter needs an MCP server to provide the 30 editing tools.
41
+ The user installed this skill from a directory but hasn't set up the MCP server yet. OpenWriter needs an MCP server to provide the 31 editing tools.
42
42
 
43
43
  **Step 1:** Tell the user to install globally and add the MCP server:
44
44
 
@@ -123,6 +123,7 @@ After editing, tell the user:
123
123
  | `tag_doc` | Add a tag to a document (stored in doc frontmatter) |
124
124
  | `untag_doc` | Remove a tag from a document (stored in doc frontmatter) |
125
125
  | `move_doc` | Move a document to a different container or root level |
126
+ | `rename_item` | Rename a workspace, container, or document (type: workspace/container/document) |
126
127
 
127
128
  ### Text Operations
128
129
 
@@ -134,7 +135,7 @@ After editing, tell the user:
134
135
 
135
136
  | Tool | Description |
136
137
  |------|-------------|
137
- | `generate_image` | Generate an image via Gemini Imagen 4 — optionally set as article cover (requires GEMINI_API_KEY) |
138
+ | `generate_image` | Generate an image via Gemini Nano Banana 2 — optionally set as article cover (requires GEMINI_API_KEY) |
138
139
 
139
140
  ### Version Management
140
141
 
@@ -153,7 +154,7 @@ OpenWriter has two distinct modes: **editing** existing documents and **creating
153
154
 
154
155
  For making changes to existing documents — rewrites, insertions, deletions:
155
156
 
156
- - Use `write_to_pad` for all edits
157
+ - Use `write_to_pad` for all edits — **`filename` is required**
157
158
  - Send **3-8 changes per call** for a responsive, streaming feel
158
159
  - Always `read_pad` before editing to get fresh node IDs
159
160
  - Respect `pendingChanges > 0` — wait for the user to accept/reject before sending more
@@ -202,7 +203,7 @@ This eliminates the need for separate `create_workspace`, `create_container`, an
202
203
  ```
203
204
  1. get_pad_status → check pendingChanges and userSignaledReview
204
205
  2. read_pad → get full document with node IDs
205
- 3. write_to_pad send changes (3-8 per call)
206
+ 3. write_to_pad({ filename: "Doc.md", changes: [...] })
206
207
  4. Wait → user accepts/rejects in browser
207
208
  ```
208
209
 
@@ -210,9 +211,9 @@ This eliminates the need for separate `create_workspace`, `create_container`, an
210
211
 
211
212
  ```
212
213
  1. list_documents → see all docs, find target
213
- 2. switch_document save current, load target (returns content)
214
- 3. read_pad → read full content with node IDs
215
- 4. write_to_pad apply edits
214
+ 2. read_pad read active doc (or switch_document first)
215
+ 3. write_to_pad({ filename: "Target.md", changes: [...] })
216
+ edits go to the named file, no view switch needed
216
217
  ```
217
218
 
218
219
  ### Creating new content (two-step)