openwriter 0.6.6 → 0.6.8

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-CZrV9Ryb.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-BuXVL8Bc.css">
13
+ <script type="module" crossorigin src="/assets/index-iGNTYLM-.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-CeGtzvqW.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -102,6 +102,7 @@ export function listDocuments() {
102
102
  ...(data.docId ? { docId: data.docId } : {}),
103
103
  ...(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
104
  ...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : {}),
105
+ ...(data.newsletterContext ? { isNewsletter: true } : {}),
105
106
  };
106
107
  }
107
108
  catch {
@@ -119,7 +120,13 @@ export function listDocuments() {
119
120
  const stat = statSync(extPath);
120
121
  const raw = readFileSync(extPath, 'utf-8');
121
122
  const { data, content } = matter(raw);
122
- const title = data.title || 'Untitled';
123
+ let title = data.title || 'Untitled';
124
+ // Title fallback: use filename stem for external files without a title
125
+ if (title === 'Untitled') {
126
+ const stem = extPath.split(/[/\\]/).pop()?.replace(/\.md$/i, '');
127
+ if (stem)
128
+ title = stem;
129
+ }
123
130
  const trimmed = content.trim();
124
131
  const wordCount = trimmed ? trimmed.split(/\s+/).length : 0;
125
132
  files.push({
@@ -132,6 +139,7 @@ export function listDocuments() {
132
139
  ...(data.docId ? { docId: data.docId } : {}),
133
140
  ...(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 } : {}),
134
141
  ...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : {}),
142
+ ...(data.newsletterContext ? { isNewsletter: true } : {}),
135
143
  });
136
144
  }
137
145
  catch { /* skip unreadable external files */ }
@@ -358,7 +366,7 @@ export function switchDocument(filename) {
358
366
  // Check cache first — preserves stable node IDs across switches
359
367
  const cached = getCachedDocument(targetPath);
360
368
  if (cached) {
361
- setActiveDocument(cached.document, cached.title, targetPath, cached.isTemp, cached.lastModified, cached.metadata);
369
+ setActiveDocument(cached.document, cached.title, targetPath, cached.isTemp, cached.lastModified, cached.metadata, cached.originalFrontmatter);
362
370
  return { document: getDocument(), title: getTitle(), filename };
363
371
  }
364
372
  const raw = readFileSync(targetPath, 'utf-8');
@@ -367,7 +375,7 @@ export function switchDocument(filename) {
367
375
  // Ensure docId exists on loaded doc metadata (lazy migration)
368
376
  ensureDocId(parsed.metadata);
369
377
  const baseName = targetPath.split(/[/\\]/).pop() || '';
370
- setActiveDocument(parsed.document, parsed.title, targetPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata);
378
+ setActiveDocument(parsed.document, parsed.title, targetPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata, parsed.rawFrontmatter);
371
379
  return { document: getDocument(), title: getTitle(), filename };
372
380
  }
373
381
  export function createDocument(title, content, path) {
@@ -486,7 +494,7 @@ export async function deleteDocument(filename) {
486
494
  throw new Error('Cannot delete the only document');
487
495
  }
488
496
  const isDeletingActive = targetPath === getFilePath();
489
- if (existsSync(targetPath)) {
497
+ if (!isExternalDoc(filename) && existsSync(targetPath)) {
490
498
  await trash(targetPath);
491
499
  }
492
500
  if (isDeletingActive) {
@@ -552,7 +560,7 @@ export function openFile(fullPath) {
552
560
  // Check cache first — preserves stable node IDs
553
561
  const cached = getCachedDocument(fullPath);
554
562
  if (cached) {
555
- setActiveDocument(cached.document, cached.title, fullPath, cached.isTemp, cached.lastModified, cached.metadata);
563
+ setActiveDocument(cached.document, cached.title, fullPath, cached.isTemp, cached.lastModified, cached.metadata, cached.originalFrontmatter);
556
564
  const filename = isExternalDoc(fullPath) ? fullPath : (fullPath.split(/[/\\]/).pop() || '');
557
565
  return { document: getDocument(), title: getTitle(), filename };
558
566
  }
@@ -560,8 +568,15 @@ export function openFile(fullPath) {
560
568
  const parsed = markdownToTiptap(raw);
561
569
  const mtime = new Date(statSync(fullPath).mtimeMs);
562
570
  ensureDocId(parsed.metadata);
571
+ // Title fallback: use filename stem instead of "Untitled" for files without a title
572
+ let title = parsed.title;
573
+ if (title === 'Untitled') {
574
+ const stem = fullPath.split(/[/\\]/).pop()?.replace(/\.md$/i, '');
575
+ if (stem)
576
+ title = stem;
577
+ }
563
578
  const baseName = fullPath.split(/[/\\]/).pop() || '';
564
- setActiveDocument(parsed.document, parsed.title, fullPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata);
579
+ setActiveDocument(parsed.document, title, fullPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata, parsed.rawFrontmatter);
565
580
  // Use full path as filename for external docs, basename for getDataDir() docs
566
581
  const filename = isExternalDoc(fullPath) ? fullPath : baseName;
567
582
  return { document: getDocument(), title: getTitle(), filename };
@@ -30,7 +30,7 @@ import { createSchedulerRouter } from './scheduler-routes.js';
30
30
  import { createBlogRouter } from './blog-routes.js';
31
31
  import { platformFetch, isAuthenticated } from './connections.js';
32
32
  import { PluginManager } from './plugin-manager.js';
33
- import { checkForUpdate } from './update-check.js';
33
+ import { checkForUpdate, getUpdateInfo, getCurrentVersion } from './update-check.js';
34
34
  import { addMark, getMarks, resolveMarks } from './marks.js';
35
35
  const __filename = fileURLToPath(import.meta.url);
36
36
  const __dirname = dirname(__filename);
@@ -74,6 +74,10 @@ export async function startHttpServer(options = {}) {
74
74
  res.status(500).json({ content: [{ type: 'text', text: `Error: ${err.message}` }] });
75
75
  }
76
76
  });
77
+ app.get('/api/update-info', (_req, res) => {
78
+ const latestVersion = getUpdateInfo();
79
+ res.json({ updateAvailable: latestVersion, currentVersion: getCurrentVersion() });
80
+ });
77
81
  app.get('/api/document', (_req, res) => {
78
82
  res.json({ document: getDocument(), title: getTitle(), metadata: getMetadata() });
79
83
  });
@@ -20,7 +20,8 @@ md.use(markdownItMark);
20
20
  md.use(markdownItSub);
21
21
  md.use(markdownItSup);
22
22
  export function markdownToTiptap(markdown) {
23
- const { data, content } = matter(markdown);
23
+ const result = matter(markdown);
24
+ const { data, content } = result;
24
25
  const title = data.title || 'Untitled';
25
26
  const tokens = md.parse(content, {});
26
27
  const docContent = tokensToTiptap(tokens);
@@ -35,7 +36,7 @@ export function markdownToTiptap(markdown) {
35
36
  // Strip pending from returned metadata (consumed into node attrs)
36
37
  const metadata = { ...data };
37
38
  delete metadata.pending;
38
- return { title, metadata, document: doc };
39
+ return { title, metadata, document: doc, rawFrontmatter: result.matter || null };
39
40
  }
40
41
  /**
41
42
  * Rehydrate pending state from frontmatter into leaf block node attrs.
@@ -82,6 +82,10 @@ export function tiptapToMarkdown(doc, title, metadata) {
82
82
  const body = nodesToMarkdown(doc.content || []);
83
83
  return frontmatter + body;
84
84
  }
85
+ /** Convert TipTap document to markdown body only (no frontmatter). */
86
+ export function tiptapToBody(doc) {
87
+ return nodesToMarkdown(doc.content || []);
88
+ }
85
89
  function nodesToMarkdown(nodes) {
86
90
  let result = '';
87
91
  for (const node of nodes) {
@@ -2,5 +2,5 @@
2
2
  * Barrel re-export for markdown serialization and parsing.
3
3
  * All existing imports from './markdown.js' continue to work unchanged.
4
4
  */
5
- export { tiptapToMarkdown, nodeText, inlineToMarkdown } from './markdown-serialize.js';
5
+ export { tiptapToMarkdown, tiptapToBody, nodeText, inlineToMarkdown } from './markdown-serialize.js';
6
6
  export { markdownToTiptap, markdownToNodes } from './markdown-parse.js';
@@ -9,7 +9,7 @@ import { randomUUID } from 'crypto';
9
9
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
11
  import { z } from 'zod';
12
- import { getDataDir, ensureDataDir, resolveDocPath } from './helpers.js';
12
+ import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId } from './helpers.js';
13
13
  import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, getMetadata, setMetadata, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, } from './state.js';
14
14
  import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId } from './documents.js';
15
15
  import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
@@ -826,37 +826,66 @@ export const TOOL_REGISTRY = [
826
826
  return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
827
827
  }
828
828
  const filename = resolveDocId(docId);
829
- // Generate image via Gemini
830
- const { GoogleGenAI } = await import('@google/genai');
831
- const ai = new GoogleGenAI({ apiKey });
832
- const response = await ai.models.generateContent({
833
- model: 'gemini-3.1-flash-image-preview',
834
- contents: `Generate a ${aspect_ratio || '16:9'} aspect ratio image: ${prompt}`,
835
- config: {
836
- responseModalities: ['IMAGE'],
837
- },
838
- });
839
- const parts = response.candidates?.[0]?.content?.parts;
840
- const imagePart = parts?.find((p) => p.inlineData);
841
- if (!imagePart?.inlineData?.data) {
842
- return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
843
- }
844
- // Save to ~/.openwriter/_images/
845
- ensureDataDir();
846
- const imagesDir = join(getDataDir(), '_images');
847
- if (!existsSync(imagesDir))
848
- mkdirSync(imagesDir, { recursive: true });
849
- const imgFilename = `${randomUUID().slice(0, 8)}.png`;
850
- const filePath = join(imagesDir, imgFilename);
851
- writeFileSync(filePath, Buffer.from(imagePart.inlineData.data, 'base64'));
852
- const src = `/_images/${imgFilename}`;
853
- // Build image node and insert change
854
- const imageNode = { type: 'image', attrs: { src, alt: alt || prompt } };
855
- const change = { operation: 'insert', afterNodeId, content: [imageNode] };
856
829
  const targetIsNonActive = filename && filename !== getActiveFilename();
857
- if (targetIsNonActive) {
858
- const { lastNodeId } = applyChangesToFile(filename, [change]);
859
- broadcastPendingDocsChanged();
830
+ // Phase 1: Insert imageLoading placeholder immediately (active doc only)
831
+ const loadingNodeId = generateNodeId();
832
+ if (!targetIsNonActive) {
833
+ const loadingChange = {
834
+ operation: 'insert',
835
+ afterNodeId,
836
+ content: [{ type: 'imageLoading', attrs: { id: loadingNodeId } }],
837
+ };
838
+ applyChanges([loadingChange]);
839
+ }
840
+ try {
841
+ // Generate image via Gemini
842
+ const { GoogleGenAI } = await import('@google/genai');
843
+ const ai = new GoogleGenAI({ apiKey });
844
+ const response = await ai.models.generateContent({
845
+ model: 'gemini-3.1-flash-image-preview',
846
+ contents: `Generate a ${aspect_ratio || '16:9'} aspect ratio image: ${prompt}`,
847
+ config: {
848
+ responseModalities: ['IMAGE'],
849
+ },
850
+ });
851
+ const parts = response.candidates?.[0]?.content?.parts;
852
+ const imagePart = parts?.find((p) => p.inlineData);
853
+ if (!imagePart?.inlineData?.data) {
854
+ // Remove placeholder on failure
855
+ if (!targetIsNonActive) {
856
+ applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
857
+ }
858
+ return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
859
+ }
860
+ // Save to ~/.openwriter/_images/
861
+ ensureDataDir();
862
+ const imagesDir = join(getDataDir(), '_images');
863
+ if (!existsSync(imagesDir))
864
+ mkdirSync(imagesDir, { recursive: true });
865
+ const imgFilename = `${randomUUID().slice(0, 8)}.png`;
866
+ const filePath = join(imagesDir, imgFilename);
867
+ writeFileSync(filePath, Buffer.from(imagePart.inlineData.data, 'base64'));
868
+ const src = `/_images/${imgFilename}`;
869
+ // Phase 2: Replace placeholder with real image (or direct insert for non-active)
870
+ if (targetIsNonActive) {
871
+ const imageNode = { type: 'image', attrs: { src, alt: alt || prompt } };
872
+ const change = { operation: 'insert', afterNodeId, content: [imageNode] };
873
+ const { lastNodeId } = applyChangesToFile(filename, [change]);
874
+ broadcastPendingDocsChanged();
875
+ return {
876
+ content: [{
877
+ type: 'text',
878
+ text: JSON.stringify({ success: true, src, ...(lastNodeId ? { lastNodeId } : {}) }),
879
+ }],
880
+ };
881
+ }
882
+ const imageNode = { type: 'image', attrs: { src, alt: alt || prompt } };
883
+ const rewriteChange = {
884
+ operation: 'rewrite',
885
+ nodeId: loadingNodeId,
886
+ content: [imageNode],
887
+ };
888
+ const { lastNodeId } = applyChanges([rewriteChange]);
860
889
  return {
861
890
  content: [{
862
891
  type: 'text',
@@ -864,13 +893,16 @@ export const TOOL_REGISTRY = [
864
893
  }],
865
894
  };
866
895
  }
867
- const { lastNodeId } = applyChanges([change]);
868
- return {
869
- content: [{
870
- type: 'text',
871
- text: JSON.stringify({ success: true, src, ...(lastNodeId ? { lastNodeId } : {}) }),
872
- }],
873
- };
896
+ catch (err) {
897
+ // Remove placeholder on error
898
+ if (!targetIsNonActive) {
899
+ try {
900
+ applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
901
+ }
902
+ catch { }
903
+ }
904
+ return { content: [{ type: 'text', text: `Error: ${err.message || 'Image generation failed.'}` }] };
905
+ }
874
906
  },
875
907
  },
876
908
  {
@@ -6,7 +6,7 @@
6
6
  import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync, utimesSync } from 'fs';
7
7
  import { join } from 'path';
8
8
  import matter from 'gray-matter';
9
- import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
9
+ import { tiptapToMarkdown, tiptapToBody, markdownToTiptap } from './markdown.js';
10
10
  import { applyTextEditsToNode } from './text-edit.js';
11
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';
@@ -22,6 +22,7 @@ let state = {
22
22
  isTemp: true,
23
23
  lastModified: new Date(),
24
24
  docId: '',
25
+ originalFrontmatter: null,
25
26
  };
26
27
  const listeners = new Set();
27
28
  // ============================================================================
@@ -104,6 +105,105 @@ function extractText(nodes) {
104
105
  })
105
106
  .join('\n');
106
107
  }
108
+ /**
109
+ * Compute linear text from a node's inline content array.
110
+ * Matches the frontend's mapTextOffsetToPos: text chars + hardBreak=1 char.
111
+ */
112
+ function linearText(content) {
113
+ if (!content)
114
+ return '';
115
+ let out = '';
116
+ for (const child of content) {
117
+ if (child.type === 'text' && typeof child.text === 'string')
118
+ out += child.text;
119
+ else if (child.type === 'hardBreak')
120
+ out += '\n';
121
+ }
122
+ return out;
123
+ }
124
+ /**
125
+ * Tokenize text into words with character offsets.
126
+ */
127
+ function tokenize(text) {
128
+ const words = [];
129
+ const re = /\S+/g;
130
+ let match;
131
+ while ((match = re.exec(text))) {
132
+ words.push({ word: match[0], start: match.index, end: match.index + match[0].length });
133
+ }
134
+ return words;
135
+ }
136
+ /**
137
+ * Compute sub-node selection range by word-level diff.
138
+ * Finds first and last differing words → decoration spans that range.
139
+ */
140
+ function computePartialRange(origContent, newContent) {
141
+ const origText = linearText(origContent || []);
142
+ const newText = linearText(newContent || []);
143
+ if (!origText || !newText || origText === newText)
144
+ return null;
145
+ const origWords = tokenize(origText);
146
+ const newWords = tokenize(newText);
147
+ if (origWords.length === 0 || newWords.length === 0)
148
+ return null;
149
+ // First differing word from start
150
+ let firstDiff = 0;
151
+ while (firstDiff < origWords.length && firstDiff < newWords.length &&
152
+ origWords[firstDiff].word === newWords[firstDiff].word)
153
+ firstDiff++;
154
+ // All words identical (shouldn't happen since text differs, but guard)
155
+ if (firstDiff === origWords.length && firstDiff === newWords.length)
156
+ return null;
157
+ // Last differing word from end
158
+ let origEnd = origWords.length - 1;
159
+ let newEnd = newWords.length - 1;
160
+ while (origEnd >= firstDiff && newEnd >= firstDiff &&
161
+ origWords[origEnd].word === newWords[newEnd].word) {
162
+ origEnd--;
163
+ newEnd--;
164
+ }
165
+ // No common words at start or end → full rewrite, skip partial
166
+ if (firstDiff === 0 && origEnd === origWords.length - 1 && newEnd === newWords.length - 1)
167
+ return null;
168
+ // Raw character offsets from first/last changed words
169
+ let origFrom = firstDiff < origWords.length ? origWords[firstDiff].start : origText.length;
170
+ let origTo = origEnd >= firstDiff && origEnd < origWords.length ? origWords[origEnd].end : origFrom;
171
+ let newFrom = firstDiff < newWords.length ? newWords[firstDiff].start : newText.length;
172
+ let newTo = newEnd >= firstDiff && newEnd < newWords.length ? newWords[newEnd].end : newFrom;
173
+ // Snap start back to previous sentence boundary (after ". ")
174
+ const snapBack = (text, pos) => {
175
+ let i = pos - 1;
176
+ while (i > 0) {
177
+ if (text[i] === '.' && i + 1 < text.length && text[i + 1] === ' ')
178
+ return i + 2;
179
+ i--;
180
+ }
181
+ return 0; // No period found → start of text
182
+ };
183
+ // Snap end forward to next sentence boundary (the ". " or end of text)
184
+ const snapForward = (text, pos) => {
185
+ let i = pos;
186
+ while (i < text.length) {
187
+ if (text[i] === '.' && (i + 1 >= text.length || text[i + 1] === ' '))
188
+ return i + 1;
189
+ i++;
190
+ }
191
+ return text.length;
192
+ };
193
+ origFrom = snapBack(origText, origFrom);
194
+ origTo = snapForward(origText, origTo);
195
+ newFrom = snapBack(newText, newFrom);
196
+ newTo = snapForward(newText, newTo);
197
+ // If almost everything changed, full-node decoration
198
+ if ((origTo - origFrom + newTo - newFrom) >= (origText.length + newText.length) * 0.8)
199
+ return null;
200
+ return {
201
+ selectionFrom: newFrom,
202
+ selectionTo: newTo,
203
+ originalFrom: origFrom,
204
+ originalTo: origTo,
205
+ };
206
+ }
107
207
  export function getWordCount() {
108
208
  const text = getPlainText();
109
209
  return text.trim() ? text.trim().split(/\s+/).length : 0;
@@ -419,6 +519,12 @@ function applyChangesToDoc(doc, changes) {
419
519
  const isEmptyNode = !originalText.trim();
420
520
  // Only store original on first rewrite (preserve baseline for reject)
421
521
  const existingOriginal = found.parent[found.index].attrs?.pendingOriginalContent;
522
+ // Detect partial change: if only a sub-range of the node text changed,
523
+ // attach selection range attrs so the frontend decorates only that part
524
+ let partialRange = null;
525
+ if (!isEmptyNode && contentArray.length === 1) {
526
+ partialRange = computePartialRange(originalNode.content || [], contentArray[0].content || []);
527
+ }
422
528
  // First node replaces the target (rewrite or insert if empty)
423
529
  const firstNode = {
424
530
  ...contentArray[0],
@@ -427,6 +533,12 @@ function applyChangesToDoc(doc, changes) {
427
533
  id: change.nodeId,
428
534
  pendingStatus: isEmptyNode ? 'insert' : 'rewrite',
429
535
  ...(isEmptyNode ? {} : { pendingOriginalContent: existingOriginal || originalNode }),
536
+ ...(partialRange ? {
537
+ pendingSelectionFrom: partialRange.selectionFrom,
538
+ pendingSelectionTo: partialRange.selectionTo,
539
+ pendingOriginalFrom: partialRange.originalFrom,
540
+ pendingOriginalTo: partialRange.originalTo,
541
+ } : {}),
430
542
  },
431
543
  };
432
544
  // Additional nodes get inserted after as pending inserts
@@ -533,7 +645,7 @@ export function applyTextEdits(nodeId, edits) {
533
645
  return { success: true };
534
646
  }
535
647
  /** Set the active document state. Used by documents.ts for multi-doc operations. */
536
- export function setActiveDocument(doc, title, filePath, isTemp, lastModified, metadata) {
648
+ export function setActiveDocument(doc, title, filePath, isTemp, lastModified, metadata, originalFrontmatter) {
537
649
  state.document = doc;
538
650
  state.title = title;
539
651
  state.metadata = metadata || { title };
@@ -541,6 +653,7 @@ export function setActiveDocument(doc, title, filePath, isTemp, lastModified, me
541
653
  state.isTemp = isTemp;
542
654
  state.lastModified = lastModified || new Date();
543
655
  state.docId = ensureDocId(state.metadata);
656
+ state.originalFrontmatter = originalFrontmatter ?? null;
544
657
  }
545
658
  // ============================================================================
546
659
  // PENDING DOCUMENT CACHE (avoids disk scans on every broadcast)
@@ -628,6 +741,7 @@ export function cacheActiveDocument() {
628
741
  lastModified: state.lastModified,
629
742
  docId: state.docId,
630
743
  fileMtime,
744
+ originalFrontmatter: state.originalFrontmatter,
631
745
  });
632
746
  }
633
747
  /** Get a cached document if the file hasn't been modified externally. Returns null on miss or stale. */
@@ -661,6 +775,8 @@ function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
661
775
  fileMtime = statSync(filePath).mtimeMs;
662
776
  }
663
777
  catch { /* best-effort */ }
778
+ // Preserve originalFrontmatter from existing cache entry (if any)
779
+ const existing = docCache.get(filePath);
664
780
  docCache.set(filePath, {
665
781
  document: structuredClone(doc),
666
782
  metadata: structuredClone(metadata),
@@ -669,6 +785,7 @@ function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
669
785
  lastModified: new Date(),
670
786
  docId,
671
787
  fileMtime,
788
+ originalFrontmatter: existing?.originalFrontmatter ?? null,
672
789
  });
673
790
  }
674
791
  /** Reset all in-memory caches. Called on profile switch. */
@@ -684,6 +801,7 @@ export function clearAllCaches() {
684
801
  isTemp: true,
685
802
  lastModified: new Date(),
686
803
  docId: '',
804
+ originalFrontmatter: null,
687
805
  };
688
806
  }
689
807
  // ============================================================================
@@ -772,7 +890,17 @@ export function getPendingDocInfo() {
772
890
  // ============================================================================
773
891
  function writeToDisk() {
774
892
  ensureDataDir();
775
- const markdown = tiptapToMarkdown(state.document, state.title, state.metadata);
893
+ let markdown;
894
+ if (isExternalDoc(state.filePath)) {
895
+ // External files: preserve original frontmatter verbatim, no OpenWriter metadata injected
896
+ const body = tiptapToBody(state.document).replace(/(?:\s*<!-- -->\s*)+$/, '\n');
897
+ markdown = state.originalFrontmatter
898
+ ? `---\n${state.originalFrontmatter}\n---\n\n${body}`
899
+ : body;
900
+ }
901
+ else {
902
+ markdown = tiptapToMarkdown(state.document, state.title, state.metadata);
903
+ }
776
904
  if (existsSync(state.filePath)) {
777
905
  // Skip write if content is identical (prevents phantom git changes on doc switch)
778
906
  try {
@@ -1048,9 +1176,9 @@ export function addDocTag(filename, tag) {
1048
1176
  }
1049
1177
  }
1050
1178
  else {
1051
- // Non-active doc — read/write disk
1179
+ // Non-active doc — read/write disk (skip external files: tags are OpenWriter metadata)
1052
1180
  const targetPath = resolveDocPath(filename);
1053
- if (!existsSync(targetPath))
1181
+ if (isExternalDoc(targetPath) || !existsSync(targetPath))
1054
1182
  return;
1055
1183
  try {
1056
1184
  const raw = readFileSync(targetPath, 'utf-8');
@@ -1088,8 +1216,9 @@ export function removeDocTag(filename, tag) {
1088
1216
  }
1089
1217
  }
1090
1218
  else {
1219
+ // Non-active doc — read/write disk (skip external files: tags are OpenWriter metadata)
1091
1220
  const targetPath = resolveDocPath(filename);
1092
- if (!existsSync(targetPath))
1221
+ if (isExternalDoc(targetPath) || !existsSync(targetPath))
1093
1222
  return;
1094
1223
  try {
1095
1224
  const raw = readFileSync(targetPath, 'utf-8');
@@ -1127,7 +1256,16 @@ export function saveDocToFile(filename, doc) {
1127
1256
  if (hasPendingChanges(parsed.document)) {
1128
1257
  transferPendingAttrs(parsed.document, doc);
1129
1258
  }
1130
- const markdown = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
1259
+ let markdown;
1260
+ if (isExternalDoc(targetPath)) {
1261
+ const body = tiptapToBody(doc);
1262
+ markdown = parsed.rawFrontmatter
1263
+ ? `---\n${parsed.rawFrontmatter}\n---\n\n${body}`
1264
+ : body;
1265
+ }
1266
+ else {
1267
+ markdown = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
1268
+ }
1131
1269
  atomicWriteFileSync(targetPath, markdown);
1132
1270
  }
1133
1271
  catch { /* best-effort */ }
@@ -1161,7 +1299,16 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
1161
1299
  if (clearAgentCreated && parsed.metadata.agentCreated) {
1162
1300
  delete parsed.metadata.agentCreated;
1163
1301
  }
1164
- const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
1302
+ let markdown;
1303
+ if (isExternalDoc(targetPath)) {
1304
+ const body = tiptapToBody(parsed.document);
1305
+ markdown = parsed.rawFrontmatter
1306
+ ? `---\n${parsed.rawFrontmatter}\n---\n\n${body}`
1307
+ : body;
1308
+ }
1309
+ else {
1310
+ markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
1311
+ }
1165
1312
  atomicWriteFileSync(targetPath, markdown);
1166
1313
  removePendingCacheEntry(filename);
1167
1314
  }
@@ -28,7 +28,7 @@ export function compareVersions(a, b) {
28
28
  return 0;
29
29
  }
30
30
  /** Read current package version from package.json on disk. */
31
- function getCurrentVersion() {
31
+ export function getCurrentVersion() {
32
32
  try {
33
33
  const pkgPath = join(__dirname, '../../package.json');
34
34
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
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
@@ -15,7 +15,7 @@ description: |
15
15
  Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
16
16
  metadata:
17
17
  author: travsteward
18
- version: "0.2.4"
18
+ version: "0.2.5"
19
19
  repository: https://github.com/travsteward/openwriter
20
20
  license: MIT
21
21
  ---
@@ -528,6 +528,10 @@ Requires authentication via `request_login_code` + `verify_login`. All publish t
528
528
  → returns: stats (delivered, opens, clicks, bounces), per-subscriber events, recipient list
529
529
  ```
530
530
 
531
+ ## Author's Voice Plugin
532
+
533
+ When the user enables the Author's Voice plugin in Settings, install the skill — see [authors-voice.com](https://www.authors-voice.com) for install methods. The skill handles API key setup and everything else.
534
+
531
535
  ## Troubleshooting
532
536
 
533
537
  **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.