openwriter 0.8.7 → 0.9.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.
@@ -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;
@@ -24,15 +24,15 @@ import { markdownToTiptap, tiptapToMarkdown } from './markdown.js';
24
24
  import { getMarks, getMarkCount, getGlobalMarkSummary, resolveMarks } from './marks.js';
25
25
  import { readTasks, addTask, updateTask, removeTask } from './tasks.js';
26
26
  /** Map a content type string to its frontmatter metadata object. */
27
- function resolveTypeMeta(type) {
27
+ function resolveTypeMeta(type, url) {
28
28
  switch (type) {
29
- case 'tweet': return { tweetContext: { mode: 'tweet' } };
30
- case 'reply': return { tweetContext: { mode: 'reply' } };
31
- case 'quote': return { tweetContext: { mode: 'quote' } };
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
  }
@@ -271,18 +271,23 @@ export const TOOL_REGISTRY = [
271
271
  },
272
272
  {
273
273
  name: 'create_document',
274
- description: 'Create a new document. Always provide a title. By default shows a sidebar spinner — call populate_document next to deliver content and clear it. This two-step flow is REQUIRED for all content documents: create_document → populate_document. Do NOT use empty=true to skip this — empty=true is ONLY for content_type template docs (tweets, articles) that start blank and get metadata via set_metadata. If workspace is provided, the doc is automatically added to it (workspace is created if it doesn\'t exist). If container is also provided, the doc is placed inside that container (created if it doesn\'t exist).',
274
+ description: 'Create a new document. content_type is REQUIRED — use "document" for plain docs, or "tweet"/"reply"/"quote"/"article"/"linkedin"/"newsletter"/"blog" for typed docs. Always provide a title. By default shows a sidebar spinner — call populate_document next to deliver content and clear it. This two-step flow is REQUIRED for all content documents: create_document → populate_document. Use empty=true ONLY for typed docs (tweets, articles) that start blank and get written to incrementally via write_to_pad. If workspace is provided, the doc is automatically added to it (workspace is created if it doesn\'t exist). If container is also provided, the doc is placed inside that container (created if it doesn\'t exist).',
275
275
  schema: {
276
276
  title: z.string().optional().describe('Title for the new document. Defaults to "Untitled".'),
277
277
  path: z.string().optional().describe('Absolute file path to create the document at (e.g. "C:/projects/doc.md"). If omitted, creates in ~/.openwriter/.'),
278
278
  workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it doesn\'t exist.'),
279
279
  container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters", "Notes", "References"). Creates the container if it doesn\'t exist. Requires workspace.'),
280
280
  empty: z.boolean().optional().describe('ONLY for content_type template docs (tweets, articles) that start blank. Skips the spinner and switches immediately. Do NOT set this for content documents — use the two-step flow (create_document → populate_document) instead.'),
281
- content_type: z.string().optional().describe('Content type: tweet, reply, quote, article, linkedin, newsletter, or blog. Sets metadata so the doc is recognized as that type. For reply/quote, use set_metadata after creation to set the target tweet URL.'),
281
+ content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog']).describe('Required. Use "document" for plain documents. Tweet/reply/quote/article/linkedin/newsletter/blog set type-specific metadata automatically.'),
282
+ url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote" (e.g. "https://x.com/user/status/123"). Sets tweetContext.url automatically. Ignored for other content types.'),
282
283
  },
283
- handler: async ({ title, path, workspace, container, empty, content_type }) => {
284
+ handler: async ({ title, path, workspace, container, empty, content_type, url }) => {
285
+ // Require url for reply/quote
286
+ if ((content_type === 'reply' || content_type === 'quote') && !url) {
287
+ return { content: [{ type: 'text', text: `Error: content_type "${content_type}" requires a url parameter (e.g. "https://x.com/user/status/123").` }] };
288
+ }
284
289
  // Default title from content_type if not provided
285
- if (!title && content_type) {
290
+ if (!title && content_type && content_type !== 'document') {
286
291
  const typeDefaults = {
287
292
  tweet: 'Tweet', reply: 'Reply', quote: 'Quote Tweet', article: 'Article',
288
293
  linkedin: 'LinkedIn Post', newsletter: 'Newsletter', blog: 'Blog Post',
@@ -313,7 +318,7 @@ export const TOOL_REGISTRY = [
313
318
  const result = createDocument(title, undefined, path);
314
319
  // Apply type-specific metadata
315
320
  if (content_type) {
316
- const typeMeta = resolveTypeMeta(content_type);
321
+ const typeMeta = resolveTypeMeta(content_type, url);
317
322
  if (typeMeta) {
318
323
  setMetadata(typeMeta);
319
324
  }
@@ -337,7 +342,7 @@ export const TOOL_REGISTRY = [
337
342
  }
338
343
  // Two-step flow: create file on disk WITHOUT switching the user's view.
339
344
  // The spinner persists in the sidebar until populate_document is called.
340
- const typeMeta = content_type ? resolveTypeMeta(content_type) : undefined;
345
+ const typeMeta = content_type ? resolveTypeMeta(content_type, url) : undefined;
341
346
  const result = createDocumentFile(title, path, typeMeta);
342
347
  let wsInfo = '';
343
348
  if (wsTarget) {
@@ -875,7 +880,7 @@ export const TOOL_REGISTRY = [
875
880
  },
876
881
  {
877
882
  name: 'insert_image',
878
- description: 'Generate an image via Gemini and optionally insert it inline or set it as article cover. Three modes: (1) docId + afterNodeId → generate + insert inline with pending decoration. (2) set_cover: true → generate + set as article cover. (3) Neither → generate to disk only, returns path. Requires GEMINI_API_KEY env var.',
883
+ description: 'Generate an image via Gemini and optionally insert it inline or set it as article cover. Three modes: (1) docId + afterNodeId → generate + insert inline with pending decoration. (2) set_cover: true → generate + set as article cover. (3) Neither → generate to disk only, returns path. Uses local GEMINI_API_KEY if set, otherwise falls back to publish platform API.',
879
884
  schema: {
880
885
  prompt: z.string().max(1000).describe('Gemini image generation prompt (max 1000 chars).'),
881
886
  docId: z.string().optional().describe('Target document by docId (8-char hex). Required for inline insert.'),
@@ -885,10 +890,6 @@ export const TOOL_REGISTRY = [
885
890
  set_cover: z.boolean().optional().describe('If true, set the generated image as the article cover (articleContext.coverImage in metadata).'),
886
891
  },
887
892
  handler: async ({ prompt, docId, afterNodeId, aspect_ratio, alt, set_cover }) => {
888
- const apiKey = process.env.GEMINI_API_KEY;
889
- if (!apiKey) {
890
- return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
891
- }
892
893
  const inlineMode = docId && afterNodeId;
893
894
  const filename = inlineMode ? resolveDocId(docId) : undefined;
894
895
  const targetIsNonActive = filename && filename !== getActiveFilename();
@@ -905,32 +906,70 @@ export const TOOL_REGISTRY = [
905
906
  applyChanges([loadingChange]);
906
907
  }
907
908
  try {
908
- // Generate image via Gemini
909
- const { GoogleGenAI } = await import('@google/genai');
910
- const ai = new GoogleGenAI({ apiKey });
911
- const response = await ai.models.generateContent({
912
- model: 'gemini-3.1-flash-image-preview',
913
- contents: `Generate a ${aspect_ratio || '16:9'} aspect ratio image: ${prompt}`,
914
- config: {
915
- responseModalities: ['IMAGE'],
916
- },
917
- });
918
- const parts = response.candidates?.[0]?.content?.parts;
919
- const imagePart = parts?.find((p) => p.inlineData);
920
- if (!imagePart?.inlineData?.data) {
921
- if (inlineMode && !targetIsNonActive) {
922
- applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
909
+ let imgFilename;
910
+ const apiKey = process.env.GEMINI_API_KEY;
911
+ if (apiKey) {
912
+ // Generate image via local Gemini
913
+ const { GoogleGenAI } = await import('@google/genai');
914
+ const ai = new GoogleGenAI({ apiKey });
915
+ const response = await ai.models.generateContent({
916
+ model: 'gemini-3.1-flash-image-preview',
917
+ contents: `Generate a ${aspect_ratio || '16:9'} aspect ratio image: ${prompt}`,
918
+ config: {
919
+ responseModalities: ['IMAGE'],
920
+ },
921
+ });
922
+ const parts = response.candidates?.[0]?.content?.parts;
923
+ const imagePart = parts?.find((p) => p.inlineData);
924
+ if (!imagePart?.inlineData?.data) {
925
+ if (inlineMode && !targetIsNonActive) {
926
+ applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
927
+ }
928
+ return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
929
+ }
930
+ ensureDataDir();
931
+ const imagesDir = join(getDataDir(), '_images');
932
+ if (!existsSync(imagesDir))
933
+ mkdirSync(imagesDir, { recursive: true });
934
+ imgFilename = `${randomUUID().slice(0, 8)}.png`;
935
+ writeFileSync(join(imagesDir, imgFilename), Buffer.from(imagePart.inlineData.data, 'base64'));
936
+ }
937
+ else {
938
+ // Fallback: generate via publish platform API
939
+ const { platformFetch, isAuthenticated } = await import('./connections.js');
940
+ if (!isAuthenticated()) {
941
+ if (inlineMode && !targetIsNonActive) {
942
+ try {
943
+ applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
944
+ }
945
+ catch { }
946
+ }
947
+ return { content: [{ type: 'text', text: 'Error: No GEMINI_API_KEY and publish platform not configured. Set GEMINI_API_KEY or log in to the publish plugin.' }] };
948
+ }
949
+ const res = await platformFetch('/images/generate', {
950
+ method: 'POST',
951
+ body: JSON.stringify({ prompt, aspect_ratio: aspect_ratio || '16:9' }),
952
+ });
953
+ if (!res.ok) {
954
+ const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
955
+ if (inlineMode && !targetIsNonActive) {
956
+ try {
957
+ applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
958
+ }
959
+ catch { }
960
+ }
961
+ return { content: [{ type: 'text', text: `Error: ${err.error || 'Platform generation failed'}` }] };
923
962
  }
924
- return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
963
+ const data = await res.json();
964
+ const imageRes = await fetch(data.url);
965
+ const imageBuffer = Buffer.from(await imageRes.arrayBuffer());
966
+ ensureDataDir();
967
+ const imagesDir = join(getDataDir(), '_images');
968
+ if (!existsSync(imagesDir))
969
+ mkdirSync(imagesDir, { recursive: true });
970
+ imgFilename = `${randomUUID().slice(0, 8)}.png`;
971
+ writeFileSync(join(imagesDir, imgFilename), imageBuffer);
925
972
  }
926
- // Save to ~/.openwriter/_images/
927
- ensureDataDir();
928
- const imagesDir = join(getDataDir(), '_images');
929
- if (!existsSync(imagesDir))
930
- mkdirSync(imagesDir, { recursive: true });
931
- const imgFilename = `${randomUUID().slice(0, 8)}.png`;
932
- const imgPath = join(imagesDir, imgFilename);
933
- writeFileSync(imgPath, Buffer.from(imagePart.inlineData.data, 'base64'));
934
973
  const src = `/_images/${imgFilename}`;
935
974
  // Mode 1: Inline insert
936
975
  if (inlineMode) {
@@ -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
  }
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.7",
3
+ "version": "0.9.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",