openwriter 0.8.8 → 0.9.2

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.
@@ -10,8 +10,8 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-BwbbiqD9.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-5Av_FKzU.css">
13
+ <script type="module" crossorigin src="/assets/index-BHiZqytt.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-BbzNoMAw.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -9,7 +9,7 @@ import matter from 'gray-matter';
9
9
  import trash from 'trash';
10
10
  import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
11
11
  import { parseMarkdownContent } from './compact.js';
12
- import { getDocument, getTitle, getFilePath, getIsTemp, getMetadata, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, removePendingCacheEntry, } from './state.js';
12
+ import { getDocument, getTitle, getFilePath, getIsTemp, getMetadata, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, removePendingCacheEntry, resetDocVersion, } from './state.js';
13
13
  import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
14
14
  import { ensureDocId } from './versions.js';
15
15
  import { renameDocInAllWorkspaces, removeDocFromAllWorkspaces } from './workspaces.js';
@@ -71,6 +71,22 @@ function writeDocOrder(order) {
71
71
  export function reorderDocs(orderedFilenames) {
72
72
  writeDocOrder(orderedFilenames);
73
73
  }
74
+ /** Derive content_type from frontmatter — explicit field first, then fallback from context keys. */
75
+ function deriveContentType(data) {
76
+ if (data.content_type)
77
+ return data.content_type;
78
+ if (data.tweetContext)
79
+ return data.tweetContext.mode || 'tweet';
80
+ if (data.articleContext)
81
+ return 'article';
82
+ if (data.linkedinContext)
83
+ return 'linkedin';
84
+ if (data.newsletterContext)
85
+ return 'newsletter';
86
+ if (data.blogContext)
87
+ return 'blog';
88
+ return undefined;
89
+ }
74
90
  export function listDocuments() {
75
91
  ensureDataDir();
76
92
  const currentPath = getFilePath();
@@ -103,6 +119,7 @@ export function listDocuments() {
103
119
  ...(data.newsletterContext?.lastSend?.sentAt ? { lastSent: data.newsletterContext.lastSend.sentAt } : data.tweetContext?.lastPost?.postedAt ? { lastSent: data.tweetContext.lastPost.postedAt } : data.blogContext?.lastPublish?.publishedAt ? { lastSent: data.blogContext.lastPublish.publishedAt } : data.articleContext?.lastPost?.postedAt ? { lastSent: data.articleContext.lastPost.postedAt } : data.manualPost?.postedAt ? { lastSent: data.manualPost.postedAt } : {}),
104
120
  ...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : {}),
105
121
  ...(data.newsletterContext ? { isNewsletter: true } : {}),
122
+ ...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
106
123
  };
107
124
  }
108
125
  catch {
@@ -140,6 +157,7 @@ export function listDocuments() {
140
157
  ...(data.newsletterContext?.lastSend?.sentAt ? { lastSent: data.newsletterContext.lastSend.sentAt } : data.tweetContext?.lastPost?.postedAt ? { lastSent: data.tweetContext.lastPost.postedAt } : data.blogContext?.lastPublish?.publishedAt ? { lastSent: data.blogContext.lastPublish.publishedAt } : {}),
141
158
  ...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : {}),
142
159
  ...(data.newsletterContext ? { isNewsletter: true } : {}),
160
+ ...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
143
161
  });
144
162
  }
145
163
  catch { /* skip unreadable external files */ }
@@ -363,6 +381,8 @@ export function switchDocument(filename) {
363
381
  save();
364
382
  // Cache current doc before switching (preserves node IDs)
365
383
  cacheActiveDocument();
384
+ // Reset version counter — new document starts a fresh version lineage
385
+ resetDocVersion();
366
386
  // Read target from disk — markdownToTiptap rehydrates pending state
367
387
  const targetPath = resolveDocPath(filename);
368
388
  if (!existsSync(targetPath)) {
@@ -673,3 +693,121 @@ export function promoteTempFile(newTitle) {
673
693
  renameMark(oldFilename, newFilename);
674
694
  return newFilename;
675
695
  }
696
+ // ============================================================================
697
+ // BATCH RESOLVE — accept/reject pending changes across multiple docs
698
+ // ============================================================================
699
+ const PENDING_ATTRS = ['pendingStatus', 'pendingOriginalContent', 'pendingGroupId', 'pendingSelectionFrom', 'pendingSelectionTo', 'pendingOriginalFrom', 'pendingOriginalTo'];
700
+ function clearPendingAttrs(attrs) {
701
+ const clean = { ...attrs };
702
+ for (const key of PENDING_ATTRS)
703
+ delete clean[key];
704
+ return clean;
705
+ }
706
+ /** Walk TipTap JSON, accept all pending changes in-place. Returns count of resolved nodes. */
707
+ function acceptAllInDoc(doc) {
708
+ let count = 0;
709
+ function walk(nodes) {
710
+ const result = [];
711
+ for (const node of nodes) {
712
+ const status = node.attrs?.pendingStatus;
713
+ if (status === 'delete') {
714
+ count++;
715
+ continue; // Remove delete nodes
716
+ }
717
+ if (status === 'insert' || status === 'rewrite') {
718
+ node.attrs = clearPendingAttrs(node.attrs);
719
+ count++;
720
+ }
721
+ if (node.content) {
722
+ node.content = walk(node.content);
723
+ }
724
+ result.push(node);
725
+ }
726
+ return result;
727
+ }
728
+ if (doc.content)
729
+ doc.content = walk(doc.content);
730
+ return count;
731
+ }
732
+ /** Walk TipTap JSON, reject all pending changes in-place. Returns count of resolved nodes. */
733
+ function rejectAllInDoc(doc) {
734
+ let count = 0;
735
+ function walk(nodes) {
736
+ const result = [];
737
+ for (const node of nodes) {
738
+ const status = node.attrs?.pendingStatus;
739
+ if (status === 'insert') {
740
+ count++;
741
+ continue; // Remove inserted nodes
742
+ }
743
+ if (status === 'rewrite') {
744
+ const original = node.attrs?.pendingOriginalContent;
745
+ if (original) {
746
+ // Replace with original content
747
+ result.push(original);
748
+ }
749
+ // If no original, just drop the node
750
+ count++;
751
+ continue;
752
+ }
753
+ if (status === 'delete') {
754
+ // Keep the node, just clear pending status
755
+ node.attrs = clearPendingAttrs(node.attrs);
756
+ count++;
757
+ }
758
+ if (node.content) {
759
+ node.content = walk(node.content);
760
+ }
761
+ result.push(node);
762
+ }
763
+ return result;
764
+ }
765
+ if (doc.content)
766
+ doc.content = walk(doc.content);
767
+ return count;
768
+ }
769
+ /** Resolve a single doc file on disk. Returns number of changes resolved. */
770
+ function resolveDocFile(filePath, action) {
771
+ const raw = readFileSync(filePath, 'utf-8');
772
+ const { data } = matter(raw);
773
+ // Skip docs with no pending changes
774
+ if (!data.pending)
775
+ return 0;
776
+ // Pass full raw file — markdownToTiptap calls matter() internally and rehydrates pending state
777
+ const parsed = markdownToTiptap(raw);
778
+ const doc = parsed.document;
779
+ const count = action === 'accept' ? acceptAllInDoc(doc) : rejectAllInDoc(doc);
780
+ if (count === 0)
781
+ return 0;
782
+ // Re-serialize — pending attrs are cleared so pending key will be removed from frontmatter
783
+ const newRaw = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
784
+ atomicWriteFileSync(filePath, newRaw);
785
+ return count;
786
+ }
787
+ export function batchResolve(filenames, action) {
788
+ let docsResolved = 0;
789
+ let changesResolved = 0;
790
+ for (const filename of filenames) {
791
+ const filePath = isExternalDoc(filename) ? filename : join(getDataDir(), filename);
792
+ if (!existsSync(filePath))
793
+ continue;
794
+ try {
795
+ const count = resolveDocFile(filePath, action);
796
+ if (count > 0) {
797
+ docsResolved++;
798
+ changesResolved += count;
799
+ // Active doc: update in-memory state directly (no reload flicker)
800
+ if (filePath === getFilePath()) {
801
+ const currentDoc = getDocument();
802
+ if (action === 'accept')
803
+ acceptAllInDoc(currentDoc);
804
+ else
805
+ rejectAllInDoc(currentDoc);
806
+ save();
807
+ }
808
+ }
809
+ }
810
+ catch { /* skip unreadable files */ }
811
+ }
812
+ return { docsResolved, changesResolved };
813
+ }
@@ -11,9 +11,9 @@ import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadc
11
11
  import { TOOL_REGISTRY } from './mcp.js';
12
12
  import { z } from 'zod';
13
13
  import { zodToJsonSchema } from 'zod-to-json-schema';
14
- import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, clearAllCaches } from './state.js';
14
+ import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, removePendingCacheEntry, clearAllCaches } from './state.js';
15
15
  import { syncPostHistory } from './post-sync.js';
16
- import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename } from './documents.js';
16
+ import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename, batchResolve } from './documents.js';
17
17
  import { createWorkspaceRouter } from './workspace-routes.js';
18
18
  import { createLinkRouter } from './link-routes.js';
19
19
  import { createTweetRouter } from './tweet-routes.js';
@@ -267,6 +267,32 @@ export async function startHttpServer(options = {}) {
267
267
  res.status(400).json({ error: err.message });
268
268
  }
269
269
  });
270
+ app.post('/api/documents/batch-resolve', (req, res) => {
271
+ try {
272
+ const { filenames, action } = req.body;
273
+ if (!Array.isArray(filenames) || !filenames.length) {
274
+ res.status(400).json({ error: 'filenames array is required' });
275
+ return;
276
+ }
277
+ if (action !== 'accept' && action !== 'reject') {
278
+ res.status(400).json({ error: 'action must be "accept" or "reject"' });
279
+ return;
280
+ }
281
+ const result = batchResolve(filenames, action);
282
+ if (result.docsResolved > 0) {
283
+ // Clear pending cache for resolved docs + broadcast
284
+ for (const fn of filenames)
285
+ removePendingCacheEntry(fn);
286
+ updatePendingCacheForActiveDoc();
287
+ broadcastPendingDocsChanged();
288
+ broadcastDocumentsChanged();
289
+ }
290
+ res.json(result);
291
+ }
292
+ catch (err) {
293
+ res.status(400).json({ error: err.message });
294
+ }
295
+ });
270
296
  app.post('/api/documents/open', (req, res) => {
271
297
  try {
272
298
  const { path } = req.body;
@@ -26,13 +26,13 @@ import { readTasks, addTask, updateTask, removeTask } from './tasks.js';
26
26
  /** Map a content type string to its frontmatter metadata object. */
27
27
  function resolveTypeMeta(type, url) {
28
28
  switch (type) {
29
- case 'tweet': return { tweetContext: { mode: 'tweet' } };
30
- case 'reply': return { tweetContext: { mode: 'reply', ...(url ? { url } : {}) } };
31
- case 'quote': return { tweetContext: { mode: 'quote', ...(url ? { url } : {}) } };
32
- case 'article': return { articleContext: { active: true } };
33
- case 'linkedin': return { linkedinContext: { active: true } };
34
- case 'newsletter': return { newsletterContext: { active: true } };
35
- case 'blog': return { blogContext: { active: true } };
29
+ case 'tweet': return { content_type: 'tweet', tweetContext: { mode: 'tweet' } };
30
+ case 'reply': return { content_type: 'reply', tweetContext: { mode: 'reply', ...(url ? { url } : {}) } };
31
+ case 'quote': return { content_type: 'quote', tweetContext: { mode: 'quote', ...(url ? { url } : {}) } };
32
+ case 'article': return { content_type: 'article', articleContext: { active: true } };
33
+ case 'linkedin': return { content_type: 'linkedin', linkedinContext: { active: true } };
34
+ case 'newsletter': return { content_type: 'newsletter', newsletterContext: { active: true } };
35
+ case 'blog': return { content_type: 'blog', blogContext: { active: true } };
36
36
  default: return undefined;
37
37
  }
38
38
  }
@@ -456,6 +456,24 @@ export function setAgentLock() {
456
456
  export function isAgentLocked() {
457
457
  return Date.now() - lastAgentWriteTime < AGENT_LOCK_MS;
458
458
  }
459
+ // ---- Document version counter: prevents stale browser doc-updates ----
460
+ let docVersion = 0;
461
+ /** Increment version after agent writes. Returns the new version. */
462
+ export function bumpDocVersion() {
463
+ return ++docVersion;
464
+ }
465
+ /** Get current document version. */
466
+ export function getDocVersion() {
467
+ return docVersion;
468
+ }
469
+ /** Check if a browser doc-update version is current. */
470
+ export function isVersionCurrent(browserVersion) {
471
+ return browserVersion >= docVersion;
472
+ }
473
+ /** Reset version on document switch (new document = new version lineage). */
474
+ export function resetDocVersion() {
475
+ docVersion = 0;
476
+ }
459
477
  // ---- Debounced save: coalesces rapid agent writes into a single disk write ----
460
478
  let saveTimer = null;
461
479
  const SAVE_DEBOUNCE_MS = 500;
@@ -477,11 +495,12 @@ export function cancelDebouncedSave() {
477
495
  export function applyChanges(changes) {
478
496
  // Apply to server-side document (source of truth)
479
497
  const processed = applyChangesToDocument(changes);
480
- // Lock browser doc-updates to prevent stale state overwrite
498
+ // Bump version + lock browser doc-updates to prevent stale state overwrite
499
+ const version = bumpDocVersion();
481
500
  setAgentLock();
482
- // Broadcast processed changes (with server-assigned IDs) to browser clients
501
+ // Broadcast processed changes (with server-assigned IDs + version) to browser clients
483
502
  for (const listener of listeners) {
484
- listener(processed);
503
+ listener(processed, version);
485
504
  }
486
505
  // Debounced save — coalesces rapid agent writes into a single disk write
487
506
  debouncedSave();
@@ -543,6 +562,10 @@ function findNodeInDoc(nodes, id) {
543
562
  */
544
563
  function applyChangesToDoc(doc, changes) {
545
564
  const processed = [];
565
+ // Track last insert anchor → last inserted node ID, so consecutive inserts
566
+ // with the same afterNodeId chain naturally (array order = document order).
567
+ let lastInsertAnchor = null;
568
+ let lastInsertedId = null;
546
569
  for (const change of changes) {
547
570
  if (change.operation === 'rewrite' && change.nodeId && change.content) {
548
571
  const found = findNode(doc.content, change.nodeId, doc.content);
@@ -608,6 +631,12 @@ function applyChangesToDoc(doc, changes) {
608
631
  // Mark leaf blocks as pending (not containers) for correct serialization
609
632
  markLeafBlocksAsPending(contentWithIds, 'insert');
610
633
  let resolvedAfterId;
634
+ // Auto-chain: if this insert targets the same anchor as the previous insert,
635
+ // redirect it to insert after the last inserted node instead (preserves array order).
636
+ let effectiveAfterId = change.afterNodeId;
637
+ if (effectiveAfterId && effectiveAfterId === lastInsertAnchor && lastInsertedId) {
638
+ effectiveAfterId = lastInsertedId;
639
+ }
611
640
  if (change.nodeId && !change.afterNodeId) {
612
641
  // Replace empty node
613
642
  const found = findNode(doc.content, change.nodeId, doc.content);
@@ -615,8 +644,8 @@ function applyChangesToDoc(doc, changes) {
615
644
  continue;
616
645
  found.parent.splice(found.index, 1, ...contentWithIds);
617
646
  }
618
- else if (change.afterNodeId) {
619
- const found = findNode(doc.content, change.afterNodeId, doc.content);
647
+ else if (effectiveAfterId) {
648
+ const found = findNode(doc.content, effectiveAfterId, doc.content);
620
649
  if (!found)
621
650
  continue;
622
651
  // Resolve "end" sentinel to actual node ID so browser can find it
@@ -626,11 +655,21 @@ function applyChangesToDoc(doc, changes) {
626
655
  else {
627
656
  continue;
628
657
  }
629
- // Broadcast with server-assigned IDs so browser uses the same IDs
658
+ // Track for auto-chaining: remember original anchor + last inserted ID
659
+ const newLastId = contentWithIds[contentWithIds.length - 1]?.attrs?.id;
660
+ if (change.afterNodeId && newLastId) {
661
+ if (change.afterNodeId !== lastInsertAnchor) {
662
+ // New anchor — start fresh chain
663
+ lastInsertAnchor = change.afterNodeId;
664
+ }
665
+ lastInsertedId = newLastId;
666
+ }
667
+ // Broadcast with server-assigned IDs and resolved anchor so browser inserts at the correct position
630
668
  processed.push({
631
669
  ...change,
632
- // Replace "end" with the resolved node ID so browser can look it up
633
- ...(resolvedAfterId && change.afterNodeId === 'end' ? { afterNodeId: resolvedAfterId } : {}),
670
+ afterNodeId: resolvedAfterId && change.afterNodeId === 'end'
671
+ ? resolvedAfterId
672
+ : effectiveAfterId ?? change.afterNodeId,
634
673
  content: contentWithIds.length === 1 ? contentWithIds[0] : contentWithIds,
635
674
  });
636
675
  }
@@ -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, renameWorkspace, reorderContainer, } from './workspaces.js';
6
+ import { listWorkspaces, getWorkspace, createWorkspace, deleteWorkspace, reorderWorkspaces, addDoc, removeDoc, moveDoc, reorderDoc, addContainerToWorkspace, removeContainer, renameContainer, renameWorkspace, reorderContainer, crossMoveContainer, promoteContainerToWorkspace, } from './workspaces.js';
7
7
  export function createWorkspaceRouter(b) {
8
8
  const router = Router();
9
9
  router.get('/api/workspaces', (_req, res) => {
@@ -36,6 +36,20 @@ export function createWorkspaceRouter(b) {
36
36
  res.status(400).json({ error: err.message });
37
37
  }
38
38
  });
39
+ // Promote container to standalone workspace
40
+ router.post('/api/workspaces/promote-container', (req, res) => {
41
+ try {
42
+ const { sourceWorkspace, containerId, afterWorkspaceFilename } = req.body;
43
+ if (!sourceWorkspace || !containerId)
44
+ return res.status(400).json({ error: 'sourceWorkspace and containerId required' });
45
+ const result = promoteContainerToWorkspace(sourceWorkspace, containerId, afterWorkspaceFilename ?? null);
46
+ b.broadcastWorkspacesChanged();
47
+ res.json(result);
48
+ }
49
+ catch (err) {
50
+ res.status(400).json({ error: err.message });
51
+ }
52
+ });
39
53
  router.get('/api/workspaces/:filename', (req, res) => {
40
54
  try {
41
55
  res.json(getWorkspace(req.params.filename));
@@ -149,6 +163,20 @@ export function createWorkspaceRouter(b) {
149
163
  res.status(400).json({ error: err.message });
150
164
  }
151
165
  });
166
+ // Cross-workspace container move
167
+ router.post('/api/workspaces/:targetFilename/containers/:containerId/cross-move', (req, res) => {
168
+ try {
169
+ const { sourceWorkspace } = req.body;
170
+ if (!sourceWorkspace)
171
+ return res.status(400).json({ error: 'sourceWorkspace required' });
172
+ const ws = crossMoveContainer(sourceWorkspace, req.params.targetFilename, req.params.containerId, req.body.targetContainerId ?? null, req.body.afterIdentifier ?? null);
173
+ b.broadcastWorkspacesChanged();
174
+ res.json(ws);
175
+ }
176
+ catch (err) {
177
+ res.status(400).json({ error: err.message });
178
+ }
179
+ });
152
180
  // Cross-workspace move (from one workspace to another)
153
181
  router.post('/api/workspaces/:targetFilename/docs/:docFile/cross-move', (req, res) => {
154
182
  try {
@@ -252,6 +252,51 @@ export function moveContainer(wsFile, containerId, targetContainerId, afterIdent
252
252
  writeWorkspace(wsFile, ws);
253
253
  return ws;
254
254
  }
255
+ export function crossMoveContainer(sourceWsFile, targetWsFile, containerId, targetContainerId, afterIdentifier) {
256
+ const sourceWs = getWorkspace(sourceWsFile);
257
+ const removed = removeNode(sourceWs.root, containerId);
258
+ if (removed.type !== 'container')
259
+ throw new Error(`Node "${containerId}" is not a container`);
260
+ writeWorkspace(sourceWsFile, sourceWs);
261
+ const targetWs = getWorkspace(targetWsFile);
262
+ // Insert into target — reuse moveNode-style logic
263
+ const target = targetContainerId === null
264
+ ? targetWs.root
265
+ : (() => { const f = findContainer(targetWs.root, targetContainerId); if (!f)
266
+ throw new Error('Target container not found'); return f.node.items; })();
267
+ if (afterIdentifier === null) {
268
+ target.unshift(removed);
269
+ }
270
+ else {
271
+ const afterIdx = target.findIndex((n) => (n.type === 'doc' && n.file === afterIdentifier) || (n.type === 'container' && n.id === afterIdentifier));
272
+ if (afterIdx === -1)
273
+ target.push(removed);
274
+ else
275
+ target.splice(afterIdx + 1, 0, removed);
276
+ }
277
+ writeWorkspace(targetWsFile, targetWs);
278
+ return targetWs;
279
+ }
280
+ export function promoteContainerToWorkspace(sourceWsFile, containerId, afterWorkspaceFilename) {
281
+ const sourceWs = getWorkspace(sourceWsFile);
282
+ const removed = removeNode(sourceWs.root, containerId);
283
+ if (removed.type !== 'container')
284
+ throw new Error(`Node "${containerId}" is not a container`);
285
+ writeWorkspace(sourceWsFile, sourceWs);
286
+ // Create new workspace with container's children as root
287
+ const slug = sanitizeFilename(removed.name).toLowerCase().replace(/\s+/g, '-');
288
+ const filename = `${slug}-${randomUUID().slice(0, 8)}.json`;
289
+ const workspace = { version: 2, title: removed.name, root: removed.items };
290
+ writeWorkspace(filename, workspace);
291
+ // Append to order then reposition
292
+ const order = readOrder();
293
+ order.push(filename);
294
+ writeOrder(order);
295
+ if (afterWorkspaceFilename !== null || order.length > 1) {
296
+ reorderWorkspaceAfter(filename, afterWorkspaceFilename);
297
+ }
298
+ return { filename, title: removed.name, docCount: countDocs(removed.items) };
299
+ }
255
300
  export function reorderWorkspaceAfter(filename, afterFilename) {
256
301
  ensureWorkspacesDir();
257
302
  const order = readOrder();
package/dist/server/ws.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * WebSocket handler: pushes NodeChanges to browser, receives doc updates + signals.
3
3
  */
4
4
  import { WebSocketServer, WebSocket } from 'ws';
5
- import { updateDocument, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, onChanges, isAgentLocked, setAgentLock, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, } from './state.js';
5
+ import { updateDocument, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, onChanges, isAgentLocked, setAgentLock, getDocVersion, isVersionCurrent, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, } from './state.js';
6
6
  import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile } from './documents.js';
7
7
  import { removeDocFromAllWorkspaces } from './workspaces.js';
8
8
  const clients = new Set();
@@ -45,7 +45,7 @@ export function setupWebSocket(server) {
45
45
  },
46
46
  });
47
47
  // Push agent changes to all browser clients
48
- onChanges((changes) => {
48
+ onChanges((changes, version) => {
49
49
  // Check if changes include HR nodes in a tweet thread document.
50
50
  // Tweet editors don't support horizontalRule in their schema, so individual
51
51
  // node-changes with HRs silently fail. Send a full document resync instead,
@@ -81,7 +81,7 @@ export function setupWebSocket(server) {
81
81
  }
82
82
  }
83
83
  else {
84
- const msg = JSON.stringify({ type: 'node-changes', changes });
84
+ const msg = JSON.stringify({ type: 'node-changes', changes, version });
85
85
  for (const ws of clients) {
86
86
  if (ws.readyState === WebSocket.OPEN)
87
87
  ws.send(msg);
@@ -123,9 +123,14 @@ export function setupWebSocket(server) {
123
123
  const docContent = msg.document?.content || [];
124
124
  const nodeCount = docContent.length;
125
125
  const currentNodeCount = getDocument()?.content?.length || 0;
126
+ const browserVersion = typeof msg.version === 'number' ? msg.version : -1;
127
+ const serverVersion = getDocVersion();
126
128
  if (isAgentLocked()) {
127
129
  console.log(`[WS] doc-update BLOCKED by agent lock (browser: ${nodeCount} nodes, server: ${currentNodeCount} nodes)`);
128
130
  }
131
+ else if (browserVersion >= 0 && !isVersionCurrent(browserVersion)) {
132
+ console.log(`[WS] doc-update BLOCKED by stale version (browser: v${browserVersion}, server: v${serverVersion})`);
133
+ }
129
134
  else if (msg.filename && msg.filename !== getActiveFilename()) {
130
135
  // Browser sent a doc-update for a different document (race: server switched away).
131
136
  // Save directly to that file on disk instead of corrupting the active doc.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.8.8",
3
+ "version": "0.9.2",
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",