openwriter 0.7.0 → 0.8.0

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.
@@ -4,26 +4,24 @@
4
4
  * Exports TOOL_REGISTRY for HTTP proxy (multi-session support).
5
5
  */
6
6
  import { join } from 'path';
7
- import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
7
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync } from 'fs';
8
8
  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, generateNodeId } from './helpers.js';
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';
12
+ import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteFileSync } from './helpers.js';
13
+ import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument, invalidateDocCache, } from './state.js';
14
14
  import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId } from './documents.js';
15
- import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
16
- import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer } from './workspaces.js';
17
- import { addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument } from './state.js';
15
+ import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastMarksChanged } from './ws.js';
16
+ import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces } from './workspaces.js';
18
17
  import { findDocNode } from './workspace-tree.js';
19
18
  import { importGoogleDoc } from './gdoc-import.js';
20
19
  import { toCompactFormat, compactNodes, parseMarkdownContent, mergeParagraphsToHardBreaks } from './compact.js';
21
20
  import matter from 'gray-matter';
22
21
  import { getUpdateInfo } from './update-check.js';
23
22
  import { listVersions, forceSnapshot, restoreVersion } from './versions.js';
24
- import { markdownToTiptap } from './markdown.js';
23
+ import { markdownToTiptap, tiptapToMarkdown } from './markdown.js';
25
24
  import { getMarks, getMarkCount, getGlobalMarkSummary, resolveMarks } from './marks.js';
26
- import { broadcastMarksChanged } from './ws.js';
27
25
  /** Map a content type string to its frontmatter metadata object. */
28
26
  function resolveTypeMeta(type) {
29
27
  switch (type) {
@@ -55,17 +53,76 @@ function isTweetDoc(filename) {
55
53
  return false;
56
54
  }
57
55
  }
56
+ /** Resolve a docId to a full document target. Fast path for active doc (zero I/O). */
57
+ function resolveDocTarget(docId) {
58
+ const filename = resolveDocId(docId);
59
+ const activeFilename = getActiveFilename();
60
+ // Fast path: active document — use in-memory state
61
+ if (filename === activeFilename) {
62
+ return {
63
+ filename,
64
+ filePath: getFilePath(),
65
+ docId,
66
+ isActive: true,
67
+ document: getDocument(),
68
+ title: getTitle(),
69
+ metadata: getMetadata(),
70
+ wordCount: getWordCount(),
71
+ pendingCount: getPendingChangeCount(),
72
+ lastModified: new Date(getStatus().lastModified),
73
+ };
74
+ }
75
+ // Non-active: try cache, then disk
76
+ const filePath = resolveDocPath(filename);
77
+ const cached = getCachedDocument(filePath);
78
+ if (cached) {
79
+ const text = extractText(cached.document.content);
80
+ return {
81
+ filename,
82
+ filePath,
83
+ docId: cached.docId,
84
+ isActive: false,
85
+ document: cached.document,
86
+ title: cached.title,
87
+ metadata: cached.metadata,
88
+ wordCount: text.trim() ? text.trim().split(/\s+/).length : 0,
89
+ pendingCount: countPending(cached.document.content),
90
+ lastModified: cached.lastModified,
91
+ };
92
+ }
93
+ // Read from disk
94
+ if (!existsSync(filePath))
95
+ throw new Error(`Document file not found: ${filename}`);
96
+ const raw = readFileSync(filePath, 'utf-8');
97
+ const parsed = markdownToTiptap(raw);
98
+ const meta = parsed.metadata || {};
99
+ const resolvedDocId = meta.docId || docId;
100
+ const text = extractText(parsed.document.content);
101
+ return {
102
+ filename,
103
+ filePath,
104
+ docId: resolvedDocId,
105
+ isActive: false,
106
+ document: parsed.document,
107
+ title: parsed.title,
108
+ metadata: parsed.metadata || {},
109
+ wordCount: text.trim() ? text.trim().split(/\s+/).length : 0,
110
+ pendingCount: countPending(parsed.document.content),
111
+ lastModified: statSync(filePath).mtime,
112
+ };
113
+ }
58
114
  export const TOOL_REGISTRY = [
59
115
  {
60
116
  name: 'read_pad',
61
- description: 'Read the current document. Returns compact tagged-line format with [type:id] per node, inline markdown formatting. Much more token-efficient than JSON.',
62
- schema: {},
63
- handler: async () => {
64
- const doc = getDocument();
65
- const compact = toCompactFormat(doc, getTitle(), getWordCount(), getPendingChangeCount(), getDocId());
66
- const activeFile = getActiveFilename();
67
- const localCount = getMarkCount(activeFile);
68
- const { totalMarks: otherMarks, docCount: otherDocs } = getGlobalMarkSummary(activeFile);
117
+ description: 'Read a document by docId. Returns compact tagged-line format with [type:id] per node, inline markdown formatting. Much more token-efficient than JSON.',
118
+ schema: {
119
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
120
+ },
121
+ handler: async ({ docId }) => {
122
+ const target = resolveDocTarget(docId);
123
+ const compact = toCompactFormat(target.document, target.title, target.wordCount, target.pendingCount, target.docId, target.metadata);
124
+ const localCount = getMarkCount(target.filename);
125
+ const { totalMarks: otherMarks, docCount: otherDocs } = getGlobalMarkSummary(target.filename);
69
126
  let hint = '';
70
127
  if (localCount > 0)
71
128
  hint += `\n[${localCount} agent mark${localCount !== 1 ? 's' : ''} on this document]`;
@@ -101,6 +158,20 @@ export const TOOL_REGISTRY = [
101
158
  }
102
159
  return resolved;
103
160
  });
161
+ // Auto-clean: if doc has only a single empty paragraph and first change is
162
+ // an insert, convert to a rewrite so the empty node gets replaced silently
163
+ // (shows as green insert decoration, not a red delete).
164
+ const activeDoc = getDocument();
165
+ if (activeDoc.content?.length === 1) {
166
+ const first = activeDoc.content[0];
167
+ if (first.type === 'paragraph' && (!first.content || first.content.length === 0) && first.attrs?.id) {
168
+ const insertIdx = processed.findIndex((c) => c.operation === 'insert');
169
+ if (insertIdx !== -1) {
170
+ processed[insertIdx] = { ...processed[insertIdx], operation: 'rewrite', nodeId: first.attrs.id };
171
+ delete processed[insertIdx].afterNodeId;
172
+ }
173
+ }
174
+ }
104
175
  const targetIsNonActive = filename && filename !== getActiveFilename();
105
176
  if (targetIsNonActive) {
106
177
  const { count: appliedCount, lastNodeId } = applyChangesToFile(filename, processed);
@@ -134,10 +205,18 @@ export const TOOL_REGISTRY = [
134
205
  },
135
206
  {
136
207
  name: 'get_pad_status',
137
- description: 'Get the current status of the pad: word count, pending changes. Cheap call for polling.',
138
- schema: {},
139
- handler: async () => {
140
- const status = getStatus();
208
+ description: 'Get the status of a document: word count, pending changes. Cheap call for polling.',
209
+ schema: {
210
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
211
+ },
212
+ handler: async ({ docId }) => {
213
+ const target = resolveDocTarget(docId);
214
+ const status = {
215
+ title: target.title,
216
+ wordCount: target.wordCount,
217
+ pendingChanges: target.pendingCount,
218
+ lastModified: target.lastModified.toISOString(),
219
+ };
141
220
  const latestVersion = getUpdateInfo();
142
221
  const payload = latestVersion ? { ...status, updateAvailable: latestVersion } : status;
143
222
  return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
@@ -147,10 +226,13 @@ export const TOOL_REGISTRY = [
147
226
  name: 'get_nodes',
148
227
  description: 'Get specific nodes by ID. Returns compact tagged-line format per node.',
149
228
  schema: {
229
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
150
230
  nodeIds: z.array(z.string()).describe('Array of node IDs to retrieve'),
151
231
  },
152
- handler: async ({ nodeIds }) => {
153
- return { content: [{ type: 'text', text: compactNodes(getNodesByIds(nodeIds)) }] };
232
+ handler: async ({ docId, nodeIds }) => {
233
+ const target = resolveDocTarget(docId);
234
+ const nodes = target.isActive ? getNodesByIds(nodeIds) : findNodesByIds(target.document.content, nodeIds);
235
+ return { content: [{ type: 'text', text: compactNodes(nodes) }] };
154
236
  },
155
237
  },
156
238
  {
@@ -170,7 +252,7 @@ export const TOOL_REGISTRY = [
170
252
  },
171
253
  {
172
254
  name: 'switch_document',
173
- description: 'Switch to a different document. Saves the current document first. Returns a compact read of the newly active document. Target document by docId (8-char hex from list_documents or read_pad).',
255
+ description: 'Show a document in the user\'s browser. NOT required before reading or editing — all tools target documents by docId directly. Use only when you want to change what the user sees. Saves the current document first. Returns a compact read of the newly active document.',
174
256
  schema: {
175
257
  docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
176
258
  },
@@ -364,10 +446,12 @@ export const TOOL_REGISTRY = [
364
446
  handler: async ({ docId }) => {
365
447
  const filename = resolveDocId(docId);
366
448
  const result = await deleteDocument(filename);
449
+ removeDocFromAllWorkspaces(filename);
367
450
  if (result.switched && result.newDoc) {
368
451
  broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
369
452
  }
370
453
  broadcastDocumentsChanged();
454
+ broadcastWorkspacesChanged();
371
455
  let text = `Deleted "${filename}" (moved to trash)`;
372
456
  if (result.switched && result.newDoc) {
373
457
  text += `. Switched to "${result.newDoc.title}"`;
@@ -384,10 +468,12 @@ export const TOOL_REGISTRY = [
384
468
  handler: async ({ docId }) => {
385
469
  const filename = resolveDocId(docId);
386
470
  const result = archiveDocument(filename);
471
+ removeDocFromAllWorkspaces(filename);
387
472
  if (result.switched && result.newDoc) {
388
473
  broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
389
474
  }
390
475
  broadcastDocumentsChanged();
476
+ broadcastWorkspacesChanged();
391
477
  let text = `Archived "${filename}"`;
392
478
  if (result.switched && result.newDoc) {
393
479
  text += `. Switched to "${result.newDoc.title}"`;
@@ -410,20 +496,24 @@ export const TOOL_REGISTRY = [
410
496
  },
411
497
  {
412
498
  name: 'get_metadata',
413
- description: 'Get the JSON frontmatter metadata for the active document. Returns all key-value pairs stored in frontmatter (title, summary, characters, tags, etc.). Useful for understanding document context without reading full content.',
414
- schema: {},
415
- handler: async () => {
416
- const metadata = getMetadata();
417
- return { content: [{ type: 'text', text: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : '{}' }] };
499
+ description: 'Get the JSON frontmatter metadata for a document. Returns all key-value pairs stored in frontmatter (title, summary, characters, tags, etc.). Useful for understanding document context without reading full content.',
500
+ schema: {
501
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
502
+ },
503
+ handler: async ({ docId }) => {
504
+ const target = resolveDocTarget(docId);
505
+ return { content: [{ type: 'text', text: Object.keys(target.metadata).length > 0 ? JSON.stringify(target.metadata) : '{}' }] };
418
506
  },
419
507
  },
420
508
  {
421
509
  name: 'set_metadata',
422
- description: 'Update frontmatter metadata on the active document. Merges with existing metadata — only provided keys are changed. Use for summaries, character lists, tags, arc notes, or any organizational data. Saves to disk immediately.',
510
+ description: 'Update frontmatter metadata on a document. Merges with existing metadata — only provided keys are changed. Use for summaries, character lists, tags, arc notes, or any organizational data. Saves to disk immediately.',
423
511
  schema: {
512
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
424
513
  metadata: z.record(z.any()).describe('Key-value pairs to merge into frontmatter. Set a key to null to remove it.'),
425
514
  },
426
- handler: async ({ metadata: updates }) => {
515
+ handler: async ({ docId, metadata: updates }) => {
516
+ const target = resolveDocTarget(docId);
427
517
  const setKeys = [];
428
518
  const removed = [];
429
519
  for (const [key, value] of Object.entries(updates)) {
@@ -437,20 +527,41 @@ export const TOOL_REGISTRY = [
437
527
  const cleaned = {};
438
528
  for (const key of setKeys)
439
529
  cleaned[key] = updates[key];
440
- if (Object.keys(cleaned).length > 0)
441
- setMetadata(cleaned);
442
- const meta = getMetadata();
443
- for (const key of removed)
444
- delete meta[key];
445
- save();
446
- broadcastMetadataChanged(getMetadata());
447
- if (cleaned.title) {
448
- // Promote temp file → named file when title is set
449
- const promoted = promoteTempFile(cleaned.title);
450
- broadcastTitleChanged(cleaned.title);
451
- broadcastDocumentsChanged();
452
- if (promoted) {
453
- broadcastDocumentSwitched(getDocument(), getTitle(), promoted, getMetadata());
530
+ if (target.isActive) {
531
+ // Active doc: use in-memory path
532
+ if (Object.keys(cleaned).length > 0)
533
+ setMetadata(cleaned);
534
+ const meta = getMetadata();
535
+ for (const key of removed)
536
+ delete meta[key];
537
+ save();
538
+ broadcastMetadataChanged(getMetadata());
539
+ if (cleaned.title) {
540
+ const promoted = promoteTempFile(cleaned.title);
541
+ broadcastTitleChanged(cleaned.title);
542
+ broadcastDocumentsChanged();
543
+ if (promoted) {
544
+ broadcastDocumentSwitched(getDocument(), getTitle(), promoted, getMetadata());
545
+ }
546
+ }
547
+ }
548
+ else {
549
+ // Non-active doc: read → merge → write file
550
+ let meta = { ...target.metadata };
551
+ if (Object.keys(cleaned).length > 0) {
552
+ const merged = mergeMetadataUpdates(meta, cleaned);
553
+ if (merged)
554
+ meta = merged;
555
+ }
556
+ for (const key of removed)
557
+ delete meta[key];
558
+ const newTitle = cleaned.title || meta.title || target.title;
559
+ const markdown = tiptapToMarkdown(target.document, newTitle, meta);
560
+ atomicWriteFileSync(target.filePath, markdown);
561
+ invalidateDocCache(target.filePath);
562
+ if (cleaned.title) {
563
+ updateDocumentTitle(target.filename, cleaned.title);
564
+ broadcastDocumentsChanged();
454
565
  }
455
566
  }
456
567
  const keys = Object.keys(cleaned);
@@ -633,17 +744,18 @@ export const TOOL_REGISTRY = [
633
744
  return { content: [{ type: 'text', text: 'Error: workspaceFile is required for doc moves' }] };
634
745
  const filename = resolveDocId(itemId);
635
746
  const ws = getWorkspace(workspaceFile);
636
- const existing = findDocNode(ws.root, filename);
637
- if (existing) {
747
+ const inTarget = findDocNode(ws.root, filename);
748
+ if (inTarget) {
749
+ // Within same workspace — reorder/move to container
638
750
  moveDoc(workspaceFile, filename, targetContainerId ?? null, afterId ?? null);
639
751
  }
640
752
  else {
641
- const title = getDocTitle(filename);
642
- addDoc(workspaceFile, targetContainerId ?? null, filename, title, afterId ?? null);
753
+ // Cross-workspace move: remove from old, add to new
754
+ removeDocFromAllWorkspaces(filename);
755
+ addDoc(workspaceFile, targetContainerId ?? null, filename, getDocTitle(filename), afterId ?? null);
643
756
  }
644
757
  broadcastWorkspacesChanged();
645
- const action = existing ? 'Moved' : 'Added';
646
- return { content: [{ type: 'text', text: `${action} "${filename}"${targetContainerId ? ` to container ${targetContainerId}` : ' to root'}` }] };
758
+ return { content: [{ type: 'text', text: `Moved "${filename}"${targetContainerId ? ` to container ${targetContainerId}` : ' to root'}` }] };
647
759
  }
648
760
  if (type === 'container') {
649
761
  if (!workspaceFile)
@@ -755,104 +867,30 @@ export const TOOL_REGISTRY = [
755
867
  return { content: [{ type: 'text', text }] };
756
868
  },
757
869
  },
758
- {
759
- name: 'generate_image',
760
- description: 'Generate an image using Gemini Nano Banana 2. Saves to ~/.openwriter/_images/. Optionally sets it as the active article\'s cover image atomically. Requires GEMINI_API_KEY env var.',
761
- schema: {
762
- prompt: z.string().max(1000).describe('Image generation prompt (max 1000 chars)'),
763
- aspect_ratio: z.string().optional().describe('Aspect ratio (default "16:9"). Supported: 1:1, 9:16, 16:9, 4:3, 3:4.'),
764
- set_cover: z.boolean().optional().describe('If true, atomically set the generated image as the article cover (articleContext.coverImage in metadata).'),
765
- },
766
- handler: async ({ prompt, aspect_ratio, set_cover }) => {
767
- const apiKey = process.env.GEMINI_API_KEY;
768
- if (!apiKey) {
769
- return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
770
- }
771
- // Capture document context BEFORE the async image generation.
772
- // The active document can change during the await (user switches docs),
773
- // so we snapshot the metadata and filePath now to stay scoped.
774
- const preAwaitFilePath = getFilePath();
775
- const preAwaitMeta = structuredClone(getMetadata());
776
- const { GoogleGenAI } = await import('@google/genai');
777
- const ai = new GoogleGenAI({ apiKey });
778
- const response = await ai.models.generateContent({
779
- model: 'gemini-3.1-flash-image-preview',
780
- contents: `Generate a ${aspect_ratio || '16:9'} aspect ratio image: ${prompt}`,
781
- config: {
782
- responseModalities: ['IMAGE'],
783
- },
784
- });
785
- const parts = response.candidates?.[0]?.content?.parts;
786
- const imagePart = parts?.find((p) => p.inlineData);
787
- if (!imagePart?.inlineData?.data) {
788
- return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
789
- }
790
- // Save to ~/.openwriter/_images/
791
- ensureDataDir();
792
- const imagesDir = join(getDataDir(), '_images');
793
- if (!existsSync(imagesDir))
794
- mkdirSync(imagesDir, { recursive: true });
795
- const filename = `${randomUUID().slice(0, 8)}.png`;
796
- const filePath = join(imagesDir, filename);
797
- writeFileSync(filePath, Buffer.from(imagePart.inlineData.data, 'base64'));
798
- const src = `/_images/${filename}`;
799
- // Optionally set as article cover + append to carousel history
800
- if (set_cover) {
801
- const docChanged = getFilePath() !== preAwaitFilePath;
802
- if (docChanged) {
803
- // Active document changed during image generation — skip set_cover
804
- // to avoid leaking cover images across documents.
805
- return {
806
- content: [{
807
- type: 'text',
808
- text: JSON.stringify({ success: true, src, coverSet: false, warning: 'Active document changed during generation — cover not set. Use set_metadata to assign manually.' }),
809
- }],
810
- };
811
- }
812
- // Use LIVE metadata for coverImages (not stale pre-await snapshot)
813
- // so concurrent generate_image calls don't overwrite each other's results
814
- const liveMeta = getMetadata();
815
- const articleContext = liveMeta.articleContext || {};
816
- let existing = Array.isArray(articleContext.coverImages) ? [...articleContext.coverImages] : [];
817
- // Seed with current coverImage if array is empty (first carousel entry)
818
- if (existing.length === 0 && articleContext.coverImage) {
819
- existing = [articleContext.coverImage];
820
- }
821
- existing.push(src);
822
- articleContext.coverImage = src;
823
- articleContext.coverImages = existing;
824
- setMetadata({ articleContext });
825
- save();
826
- broadcastMetadataChanged(getMetadata());
827
- }
828
- return {
829
- content: [{
830
- type: 'text',
831
- text: JSON.stringify({ success: true, src, ...(set_cover ? { coverSet: true } : {}) }),
832
- }],
833
- };
834
- },
835
- },
836
870
  {
837
871
  name: 'insert_image',
838
- description: 'Generate an image via Gemini and insert it inline into a document. The image appears with a green pending decoration for user review. Uses the same change pipeline as write_to_pad.',
872
+ 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.',
839
873
  schema: {
840
- docId: z.string().describe('Target document by docId (8-char hex).'),
841
874
  prompt: z.string().max(1000).describe('Gemini image generation prompt (max 1000 chars).'),
842
- afterNodeId: z.string().describe('Insert after this node ID, or "end" to append at the bottom.'),
875
+ docId: z.string().optional().describe('Target document by docId (8-char hex). Required for inline insert.'),
876
+ afterNodeId: z.string().optional().describe('Insert after this node ID, or "end" to append. Required for inline insert.'),
843
877
  aspect_ratio: z.string().optional().describe('Aspect ratio (default "16:9"). Supported: 1:1, 9:16, 16:9, 4:3, 3:4.'),
844
878
  alt: z.string().optional().describe('Alt text for the image (defaults to prompt).'),
879
+ set_cover: z.boolean().optional().describe('If true, set the generated image as the article cover (articleContext.coverImage in metadata).'),
845
880
  },
846
- handler: async ({ docId, prompt, afterNodeId, aspect_ratio, alt }) => {
881
+ handler: async ({ prompt, docId, afterNodeId, aspect_ratio, alt, set_cover }) => {
847
882
  const apiKey = process.env.GEMINI_API_KEY;
848
883
  if (!apiKey) {
849
884
  return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
850
885
  }
851
- const filename = resolveDocId(docId);
886
+ const inlineMode = docId && afterNodeId;
887
+ const filename = inlineMode ? resolveDocId(docId) : undefined;
852
888
  const targetIsNonActive = filename && filename !== getActiveFilename();
853
- // Phase 1: Insert imageLoading placeholder immediately (active doc only)
889
+ // Capture context before async work (for set_cover)
890
+ const preAwaitFilePath = getFilePath();
891
+ // Phase 1: Insert imageLoading placeholder immediately (inline + active doc only)
854
892
  const loadingNodeId = generateNodeId();
855
- if (!targetIsNonActive) {
893
+ if (inlineMode && !targetIsNonActive) {
856
894
  const loadingChange = {
857
895
  operation: 'insert',
858
896
  afterNodeId,
@@ -874,8 +912,7 @@ export const TOOL_REGISTRY = [
874
912
  const parts = response.candidates?.[0]?.content?.parts;
875
913
  const imagePart = parts?.find((p) => p.inlineData);
876
914
  if (!imagePart?.inlineData?.data) {
877
- // Remove placeholder on failure
878
- if (!targetIsNonActive) {
915
+ if (inlineMode && !targetIsNonActive) {
879
916
  applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
880
917
  }
881
918
  return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
@@ -886,39 +923,63 @@ export const TOOL_REGISTRY = [
886
923
  if (!existsSync(imagesDir))
887
924
  mkdirSync(imagesDir, { recursive: true });
888
925
  const imgFilename = `${randomUUID().slice(0, 8)}.png`;
889
- const filePath = join(imagesDir, imgFilename);
890
- writeFileSync(filePath, Buffer.from(imagePart.inlineData.data, 'base64'));
926
+ const imgPath = join(imagesDir, imgFilename);
927
+ writeFileSync(imgPath, Buffer.from(imagePart.inlineData.data, 'base64'));
891
928
  const src = `/_images/${imgFilename}`;
892
- // Phase 2: Replace placeholder with real image (or direct insert for non-active)
893
- if (targetIsNonActive) {
929
+ // Mode 1: Inline insert
930
+ if (inlineMode) {
894
931
  const imageNode = { type: 'image', attrs: { src, alt: alt || prompt } };
895
- const change = { operation: 'insert', afterNodeId, content: [imageNode] };
896
- const { lastNodeId } = applyChangesToFile(filename, [change]);
897
- broadcastPendingDocsChanged();
898
- return {
899
- content: [{
900
- type: 'text',
901
- text: JSON.stringify({ success: true, src, ...(lastNodeId ? { lastNodeId } : {}) }),
902
- }],
903
- };
932
+ if (targetIsNonActive) {
933
+ const change = { operation: 'insert', afterNodeId, content: [imageNode] };
934
+ const { lastNodeId } = applyChangesToFile(filename, [change]);
935
+ broadcastPendingDocsChanged();
936
+ return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, ...(lastNodeId ? { lastNodeId } : {}) }) }] };
937
+ }
938
+ // Hard-replace: mutate server doc directly, bypass applyChanges.
939
+ // During async generation (5-10s) the agent lock expires and browser doc-updates
940
+ // can change the imageLoading node's ID. Find it by type, not stale ID.
941
+ // Then broadcast document-switched so the browser rebuilds from server truth.
942
+ const doc = getDocument();
943
+ const imgId = generateNodeId();
944
+ const pendingImage = { ...imageNode, attrs: { ...imageNode.attrs, id: imgId, pendingStatus: 'insert' } };
945
+ const idx = doc.content?.findIndex((n) => n.type === 'imageLoading') ?? -1;
946
+ if (idx >= 0) {
947
+ doc.content.splice(idx, 1, pendingImage);
948
+ }
949
+ else {
950
+ doc.content.push(pendingImage);
951
+ }
952
+ updateDocument(doc);
953
+ save();
954
+ setAgentLock();
955
+ broadcastDocumentSwitched(doc, getTitle(), getActiveFilename(), getMetadata());
956
+ return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, lastNodeId: imgId }) }] };
904
957
  }
905
- const imageNode = { type: 'image', attrs: { src, alt: alt || prompt } };
906
- const rewriteChange = {
907
- operation: 'rewrite',
908
- nodeId: loadingNodeId,
909
- content: [imageNode],
910
- };
911
- const { lastNodeId } = applyChanges([rewriteChange]);
912
- return {
913
- content: [{
914
- type: 'text',
915
- text: JSON.stringify({ success: true, src, ...(lastNodeId ? { lastNodeId } : {}) }),
916
- }],
917
- };
958
+ // Mode 2: Set as article cover
959
+ if (set_cover) {
960
+ const docChanged = getFilePath() !== preAwaitFilePath;
961
+ if (docChanged) {
962
+ return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, coverSet: false, warning: 'Active document changed during generation — cover not set.' }) }] };
963
+ }
964
+ const liveMeta = getMetadata();
965
+ const articleContext = liveMeta.articleContext || {};
966
+ let existing = Array.isArray(articleContext.coverImages) ? [...articleContext.coverImages] : [];
967
+ if (existing.length === 0 && articleContext.coverImage) {
968
+ existing = [articleContext.coverImage];
969
+ }
970
+ existing.push(src);
971
+ articleContext.coverImage = src;
972
+ articleContext.coverImages = existing;
973
+ setMetadata({ articleContext });
974
+ save();
975
+ broadcastMetadataChanged(getMetadata());
976
+ return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, coverSet: true }) }] };
977
+ }
978
+ // Mode 3: Generate to disk only
979
+ return { content: [{ type: 'text', text: JSON.stringify({ success: true, src }) }] };
918
980
  }
919
981
  catch (err) {
920
- // Remove placeholder on error
921
- if (!targetIsNonActive) {
982
+ if (inlineMode && !targetIsNonActive) {
922
983
  try {
923
984
  applyChanges([{ operation: 'delete', nodeId: loadingNodeId }]);
924
985
  }
@@ -930,13 +991,13 @@ export const TOOL_REGISTRY = [
930
991
  },
931
992
  {
932
993
  name: 'list_versions',
933
- description: 'List version history for the active document. Returns timestamps, word counts, and sizes. Use to find a timestamp for restore_version.',
934
- schema: {},
935
- handler: async () => {
936
- const docId = getDocId();
937
- if (!docId)
938
- return { content: [{ type: 'text', text: 'Error: No active document.' }] };
939
- const versions = listVersions(docId);
994
+ description: 'List version history for a document. Returns timestamps, word counts, and sizes. Use to find a timestamp for restore_version.',
995
+ schema: {
996
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
997
+ },
998
+ handler: async ({ docId }) => {
999
+ const target = resolveDocTarget(docId);
1000
+ const versions = listVersions(target.docId);
940
1001
  if (versions.length === 0)
941
1002
  return { content: [{ type: 'text', text: 'No versions found for this document.' }] };
942
1003
  const lines = versions.map((v, i) => ` ${i + 1}. ${v.date} ts:${v.timestamp} ${v.wordCount.toLocaleString()} words ${(v.size / 1024).toFixed(1)}KB`);
@@ -945,60 +1006,71 @@ export const TOOL_REGISTRY = [
945
1006
  },
946
1007
  {
947
1008
  name: 'create_checkpoint',
948
- description: 'Force a version snapshot of the active document right now. Use before risky operations as a safety net.',
949
- schema: {},
950
- handler: async () => {
951
- const docId = getDocId();
952
- const filePath = getFilePath();
953
- if (!docId || !filePath)
954
- return { content: [{ type: 'text', text: 'Error: No active document.' }] };
955
- forceSnapshot(docId, filePath);
1009
+ description: 'Force a version snapshot of a document right now. Use before risky operations as a safety net.',
1010
+ schema: {
1011
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
1012
+ },
1013
+ handler: async ({ docId }) => {
1014
+ const target = resolveDocTarget(docId);
1015
+ forceSnapshot(target.docId, target.filePath);
956
1016
  return { content: [{ type: 'text', text: `Checkpoint created at ${new Date().toISOString()}` }] };
957
1017
  },
958
1018
  },
959
1019
  {
960
1020
  name: 'restore_version',
961
- description: 'Restore the active document to a previous version by timestamp. Automatically creates a safety checkpoint of the current state first. Get timestamps from list_versions.',
1021
+ description: 'Restore a document to a previous version by timestamp. Automatically creates a safety checkpoint of the current state first. Get timestamps from list_versions.',
962
1022
  schema: {
1023
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
963
1024
  timestamp: z.number().describe('Version timestamp to restore (from list_versions)'),
964
1025
  },
965
- handler: async ({ timestamp }) => {
966
- const docId = getDocId();
967
- const filePath = getFilePath();
968
- if (!docId || !filePath)
969
- return { content: [{ type: 'text', text: 'Error: No active document.' }] };
1026
+ handler: async ({ docId, timestamp }) => {
1027
+ const target = resolveDocTarget(docId);
970
1028
  // Safety net: snapshot current state before restoring
971
1029
  try {
972
- forceSnapshot(docId, filePath);
1030
+ forceSnapshot(target.docId, target.filePath);
973
1031
  }
974
1032
  catch { /* best effort */ }
975
- const parsed = restoreVersion(docId, timestamp);
1033
+ const parsed = restoreVersion(target.docId, timestamp);
976
1034
  if (!parsed)
977
1035
  return { content: [{ type: 'text', text: `Error: Version ${timestamp} not found.` }] };
978
- updateDocument(parsed.document);
979
- save();
980
- const filename = filePath.split(/[/\\]/).pop() || '';
981
- broadcastDocumentSwitched(parsed.document, parsed.title, filename);
1036
+ if (target.isActive) {
1037
+ updateDocument(parsed.document);
1038
+ save();
1039
+ broadcastDocumentSwitched(parsed.document, parsed.title, target.filename);
1040
+ }
1041
+ else {
1042
+ // Write restored content to file without switching active doc
1043
+ const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
1044
+ atomicWriteFileSync(target.filePath, markdown);
1045
+ invalidateDocCache(target.filePath);
1046
+ broadcastDocumentsChanged();
1047
+ }
982
1048
  return { content: [{ type: 'text', text: `Restored version from ${new Date(timestamp).toISOString()} — "${parsed.title}"` }] };
983
1049
  },
984
1050
  },
985
1051
  {
986
1052
  name: 'reload_from_disk',
987
- description: 'Re-read the active document from its file on disk. Use when the file was modified externally and the editor needs to pick up changes. Does NOT rescan the full document list.',
988
- schema: {},
989
- handler: async () => {
990
- const filePath = getFilePath();
991
- if (!filePath)
992
- return { content: [{ type: 'text', text: 'Error: No active document.' }] };
993
- if (!existsSync(filePath))
994
- return { content: [{ type: 'text', text: `Error: File not found: ${filePath}` }] };
995
- const markdown = readFileSync(filePath, 'utf-8');
996
- const parsed = markdownToTiptap(markdown);
997
- updateDocument(parsed.document);
998
- save();
999
- const filename = filePath.split(/[/\\]/).pop() || '';
1000
- broadcastDocumentSwitched(parsed.document, parsed.title, filename);
1001
- return { content: [{ type: 'text', text: `Reloaded "${parsed.title}" from disk` }] };
1053
+ description: 'Re-read a document from its file on disk. Use when the file was modified externally and the editor needs to pick up changes. Does NOT rescan the full document list.',
1054
+ schema: {
1055
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
1056
+ },
1057
+ handler: async ({ docId }) => {
1058
+ const target = resolveDocTarget(docId);
1059
+ if (!existsSync(target.filePath))
1060
+ return { content: [{ type: 'text', text: `Error: File not found: ${target.filePath}` }] };
1061
+ if (target.isActive) {
1062
+ const markdown = readFileSync(target.filePath, 'utf-8');
1063
+ const parsed = markdownToTiptap(markdown);
1064
+ updateDocument(parsed.document);
1065
+ save();
1066
+ broadcastDocumentSwitched(parsed.document, parsed.title, target.filename);
1067
+ return { content: [{ type: 'text', text: `Reloaded "${parsed.title}" from disk` }] };
1068
+ }
1069
+ else {
1070
+ // Non-active: just invalidate cache so next access re-reads from disk
1071
+ invalidateDocCache(target.filePath);
1072
+ return { content: [{ type: 'text', text: `Cache invalidated for "${target.title}" — next access will re-read from disk` }] };
1073
+ }
1002
1074
  },
1003
1075
  },
1004
1076
  {