openwriter 0.6.6 → 0.6.7

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-DxAyyL3w.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-BDSezXwn.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -119,7 +119,13 @@ export function listDocuments() {
119
119
  const stat = statSync(extPath);
120
120
  const raw = readFileSync(extPath, 'utf-8');
121
121
  const { data, content } = matter(raw);
122
- const title = data.title || 'Untitled';
122
+ let title = data.title || 'Untitled';
123
+ // Title fallback: use filename stem for external files without a title
124
+ if (title === 'Untitled') {
125
+ const stem = extPath.split(/[/\\]/).pop()?.replace(/\.md$/i, '');
126
+ if (stem)
127
+ title = stem;
128
+ }
123
129
  const trimmed = content.trim();
124
130
  const wordCount = trimmed ? trimmed.split(/\s+/).length : 0;
125
131
  files.push({
@@ -358,7 +364,7 @@ export function switchDocument(filename) {
358
364
  // Check cache first — preserves stable node IDs across switches
359
365
  const cached = getCachedDocument(targetPath);
360
366
  if (cached) {
361
- setActiveDocument(cached.document, cached.title, targetPath, cached.isTemp, cached.lastModified, cached.metadata);
367
+ setActiveDocument(cached.document, cached.title, targetPath, cached.isTemp, cached.lastModified, cached.metadata, cached.originalFrontmatter);
362
368
  return { document: getDocument(), title: getTitle(), filename };
363
369
  }
364
370
  const raw = readFileSync(targetPath, 'utf-8');
@@ -367,7 +373,7 @@ export function switchDocument(filename) {
367
373
  // Ensure docId exists on loaded doc metadata (lazy migration)
368
374
  ensureDocId(parsed.metadata);
369
375
  const baseName = targetPath.split(/[/\\]/).pop() || '';
370
- setActiveDocument(parsed.document, parsed.title, targetPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata);
376
+ setActiveDocument(parsed.document, parsed.title, targetPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata, parsed.rawFrontmatter);
371
377
  return { document: getDocument(), title: getTitle(), filename };
372
378
  }
373
379
  export function createDocument(title, content, path) {
@@ -486,7 +492,7 @@ export async function deleteDocument(filename) {
486
492
  throw new Error('Cannot delete the only document');
487
493
  }
488
494
  const isDeletingActive = targetPath === getFilePath();
489
- if (existsSync(targetPath)) {
495
+ if (!isExternalDoc(filename) && existsSync(targetPath)) {
490
496
  await trash(targetPath);
491
497
  }
492
498
  if (isDeletingActive) {
@@ -552,7 +558,7 @@ export function openFile(fullPath) {
552
558
  // Check cache first — preserves stable node IDs
553
559
  const cached = getCachedDocument(fullPath);
554
560
  if (cached) {
555
- setActiveDocument(cached.document, cached.title, fullPath, cached.isTemp, cached.lastModified, cached.metadata);
561
+ setActiveDocument(cached.document, cached.title, fullPath, cached.isTemp, cached.lastModified, cached.metadata, cached.originalFrontmatter);
556
562
  const filename = isExternalDoc(fullPath) ? fullPath : (fullPath.split(/[/\\]/).pop() || '');
557
563
  return { document: getDocument(), title: getTitle(), filename };
558
564
  }
@@ -560,8 +566,15 @@ export function openFile(fullPath) {
560
566
  const parsed = markdownToTiptap(raw);
561
567
  const mtime = new Date(statSync(fullPath).mtimeMs);
562
568
  ensureDocId(parsed.metadata);
569
+ // Title fallback: use filename stem instead of "Untitled" for files without a title
570
+ let title = parsed.title;
571
+ if (title === 'Untitled') {
572
+ const stem = fullPath.split(/[/\\]/).pop()?.replace(/\.md$/i, '');
573
+ if (stem)
574
+ title = stem;
575
+ }
563
576
  const baseName = fullPath.split(/[/\\]/).pop() || '';
564
- setActiveDocument(parsed.document, parsed.title, fullPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata);
577
+ setActiveDocument(parsed.document, title, fullPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata, parsed.rawFrontmatter);
565
578
  // Use full path as filename for external docs, basename for getDataDir() docs
566
579
  const filename = isExternalDoc(fullPath) ? fullPath : baseName;
567
580
  return { document: getDocument(), title: getTitle(), filename };
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
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",