openwriter 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,17 +9,38 @@ 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 { DATA_DIR, ensureDataDir } from './helpers.js';
12
+ import { DATA_DIR, ensureDataDir, resolveDocPath } 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
- import { listDocuments, switchDocument, createDocument, deleteDocument, openFile, getActiveFilename, updateDocumentTitle } from './documents.js';
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';
16
16
  import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, renameWorkspace, renameContainer } from './workspaces.js';
17
- import { addDocTag, removeDocTag, getDocTagsByFilename } from './state.js';
17
+ import { addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument } from './state.js';
18
18
  import { importGoogleDoc } from './gdoc-import.js';
19
- import { toCompactFormat, compactNodes, parseMarkdownContent } from './compact.js';
19
+ import { toCompactFormat, compactNodes, parseMarkdownContent, mergeParagraphsToHardBreaks } from './compact.js';
20
+ import matter from 'gray-matter';
20
21
  import { getUpdateInfo } from './update-check.js';
21
22
  import { listVersions, forceSnapshot, restoreVersion } from './versions.js';
22
23
  import { markdownToTiptap } from './markdown.js';
24
+ import { getMarks, getMarkCount, getGlobalMarkSummary, resolveMarks } from './marks.js';
25
+ import { broadcastMarksChanged } from './ws.js';
26
+ /** Check if a document is in tweet compose mode (has tweetContext metadata). */
27
+ function isTweetDoc(filename) {
28
+ if (!filename || filename === getActiveFilename()) {
29
+ return !!getMetadata()?.tweetContext;
30
+ }
31
+ const targetPath = resolveDocPath(filename);
32
+ const cached = getCachedDocument(targetPath);
33
+ if (cached)
34
+ return !!cached.metadata?.tweetContext;
35
+ try {
36
+ const raw = readFileSync(targetPath, 'utf-8');
37
+ const { data } = matter(raw);
38
+ return !!data?.tweetContext;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
23
44
  export const TOOL_REGISTRY = [
24
45
  {
25
46
  name: 'read_pad',
@@ -27,13 +48,23 @@ export const TOOL_REGISTRY = [
27
48
  schema: {},
28
49
  handler: async () => {
29
50
  const doc = getDocument();
30
- const compact = toCompactFormat(doc, getTitle(), getWordCount(), getPendingChangeCount());
31
- return { content: [{ type: 'text', text: compact }] };
51
+ const compact = toCompactFormat(doc, getTitle(), getWordCount(), getPendingChangeCount(), getDocId());
52
+ const activeFile = getActiveFilename();
53
+ const localCount = getMarkCount(activeFile);
54
+ const { totalMarks: otherMarks, docCount: otherDocs } = getGlobalMarkSummary(activeFile);
55
+ let hint = '';
56
+ if (localCount > 0)
57
+ hint += `\n[${localCount} agent mark${localCount !== 1 ? 's' : ''} on this document]`;
58
+ if (otherMarks > 0)
59
+ hint += `\n[${otherMarks} agent mark${otherMarks !== 1 ? 's' : ''} on ${otherDocs} other document${otherDocs !== 1 ? 's' : ''}]`;
60
+ if (hint)
61
+ hint += '\n[call get_agent_marks to review]';
62
+ return { content: [{ type: 'text', text: compact + hint }] };
32
63
  },
33
64
  },
34
65
  {
35
66
  name: 'write_to_pad',
36
- description: 'Preferred tool for all document edits. Send 3-8 changes per call for responsive feel. Multiple rapid calls better than one monolithic call. Content can be a markdown string (preferred) or TipTap JSON. Markdown strings are auto-converted. Changes appear as pending decorations the user accepts or rejects. Use afterNodeId: "end" to append to the document without knowing node IDs. Response includes lastNodeId for chaining subsequent inserts. Always specify filename edits target that file directly without switching the user\'s view.',
67
+ description: 'Preferred tool for all document edits. Send 3-8 changes per call for responsive feel. Multiple rapid calls better than one monolithic call. Content can be a markdown string (preferred) or TipTap JSON. Markdown strings are auto-converted. Changes appear as pending decorations the user accepts or rejects. Use afterNodeId: "end" to append to the document without knowing node IDs. Response includes lastNodeId for chaining subsequent inserts. Target document by docId (8-char hex from list_documents or read_pad).',
37
68
  schema: {
38
69
  changes: z.array(z.object({
39
70
  operation: z.enum(['rewrite', 'insert', 'delete']),
@@ -41,13 +72,18 @@ export const TOOL_REGISTRY = [
41
72
  afterNodeId: z.string().optional(),
42
73
  content: z.any().optional(),
43
74
  })).describe('Array of node changes. Content accepts markdown strings or TipTap JSON.'),
44
- filename: z.string().describe('Target filename (e.g. "My Essay.md"). Required identifies which document to edit.'),
75
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
45
76
  },
46
- handler: async ({ changes, filename }) => {
77
+ handler: async ({ changes, docId }) => {
78
+ const filename = resolveDocId(docId);
79
+ const tweetMode = isTweetDoc(filename);
47
80
  const processed = changes.map((change) => {
48
81
  const resolved = { ...change };
49
82
  if (typeof resolved.content === 'string') {
50
- resolved.content = parseMarkdownContent(resolved.content);
83
+ let nodes = parseMarkdownContent(resolved.content);
84
+ if (tweetMode)
85
+ nodes = mergeParagraphsToHardBreaks(nodes);
86
+ resolved.content = nodes;
51
87
  }
52
88
  return resolved;
53
89
  });
@@ -105,30 +141,32 @@ export const TOOL_REGISTRY = [
105
141
  },
106
142
  {
107
143
  name: 'list_documents',
108
- description: 'List all documents in the workspace. Shows filename, word count, last modified date, and which document is active.',
144
+ description: 'List all documents. Shows title, docId (8-char hex), word count, last modified date, and which document is active. Use the docId to target documents in other tools.',
109
145
  schema: {},
110
146
  handler: async () => {
111
147
  const docs = listDocuments();
112
148
  const lines = docs.map((d) => {
113
149
  const active = d.isActive ? ' (active)' : '';
150
+ const id = d.docId ? ` [${d.docId}]` : '';
114
151
  const date = d.lastModified.split('T')[0];
115
- return ` ${d.filename}${active} — ${d.wordCount.toLocaleString()} words — ${date}`;
152
+ return ` "${d.title}"${id}${active} — ${d.wordCount.toLocaleString()} words — ${date}`;
116
153
  });
117
154
  return { content: [{ type: 'text', text: `documents:\n${lines.join('\n') || ' (none)'}` }] };
118
155
  },
119
156
  },
120
157
  {
121
158
  name: 'switch_document',
122
- description: 'Switch to a different document by filename. Saves the current document first. Returns a compact read of the newly active document.',
159
+ 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).',
123
160
  schema: {
124
- filename: z.string().describe('Filename of the document to switch to (e.g. "My Essay.md")'),
161
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
125
162
  },
126
- handler: async ({ filename }) => {
163
+ handler: async ({ docId }) => {
164
+ const filename = resolveDocId(docId);
127
165
  broadcastWritingFinished(); // Clear any in-progress creation spinner
128
166
  const result = switchDocument(filename);
129
167
  broadcastDocumentSwitched(result.document, result.title, result.filename);
130
- const compact = toCompactFormat(result.document, result.title, getWordCount(), getPendingChangeCount());
131
- return { content: [{ type: 'text', text: `Switched to "${result.title}"\n\n${compact}` }] };
168
+ const compact = toCompactFormat(result.document, result.title, getWordCount(), getPendingChangeCount(), getDocId());
169
+ return { content: [{ type: 'text', text: `Switched to "${result.title}" [${docId}]\n\n${compact}` }] };
132
170
  },
133
171
  },
134
172
  {
@@ -160,18 +198,16 @@ export const TOOL_REGISTRY = [
160
198
  await new Promise((resolve) => setTimeout(resolve, 200));
161
199
  }
162
200
  try {
163
- // Lock browser doc-updates: prevents race where browser sends a doc-update
164
- // for the previous document but server has already switched active doc.
165
- setAgentLock();
166
- const result = createDocument(title, undefined, path);
167
- // Auto-add to workspace if specified
168
- let wsInfo = '';
169
- if (wsTarget) {
170
- addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
171
- wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
172
- }
173
201
  if (empty) {
174
202
  // Immediate switch — no spinner, no populate_document needed
203
+ setAgentLock();
204
+ const result = createDocument(title, undefined, path);
205
+ let wsInfo = '';
206
+ if (wsTarget) {
207
+ addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
208
+ wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
209
+ }
210
+ const newDocId = getDocId();
175
211
  save();
176
212
  broadcastDocumentsChanged();
177
213
  broadcastWorkspacesChanged();
@@ -179,19 +215,23 @@ export const TOOL_REGISTRY = [
179
215
  return {
180
216
  content: [{
181
217
  type: 'text',
182
- text: `Created "${result.title}" (${result.filename})${wsInfo} — ready.`,
218
+ text: `Created "${result.title}" [${newDocId}]${wsInfo} — ready.`,
183
219
  }],
184
220
  };
185
221
  }
186
- // Two-step flow: spinner persists until populate_document is called
187
- setMetadata({ agentCreated: true });
188
- save(); // Persist agentCreated flag to frontmatter
222
+ // Two-step flow: create file on disk WITHOUT switching the user's view.
223
+ // The spinner persists in the sidebar until populate_document is called.
224
+ const result = createDocumentFile(title, path);
225
+ let wsInfo = '';
226
+ if (wsTarget) {
227
+ addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
228
+ wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
229
+ }
189
230
  broadcastDocumentsChanged();
190
- broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename());
191
231
  return {
192
232
  content: [{
193
233
  type: 'text',
194
- text: `Created "${result.title}" (${result.filename})${wsInfo} — empty. Call populate_document to add content.`,
234
+ text: `Created "${result.title}" [${result.docId}]${wsInfo} — empty. Call populate_document with docId "${result.docId}" to add content.`,
195
235
  }],
196
236
  };
197
237
  }
@@ -204,16 +244,20 @@ export const TOOL_REGISTRY = [
204
244
  },
205
245
  {
206
246
  name: 'populate_document',
207
- description: 'Populate a document with content. Use after create_document (without content) to complete the two-step creation flow. Content appears as pending decorations for user review. Clears the sidebar creation spinner and shows the document. Pass the filename from create_document\'s response to ensure content goes to the right doc even if the user switched away.',
247
+ description: 'Populate a document with content. Use after create_document (without content) to complete the two-step creation flow. Content appears as pending decorations for user review. Clears the sidebar creation spinner and shows the document. Pass the docId from create_document\'s response to ensure content goes to the right doc even if the user switched away.',
208
248
  schema: {
209
249
  content: z.any().describe('Document content: markdown string (preferred) or TipTap JSON doc object.'),
210
- filename: z.string().optional().describe('Target filename (e.g. "My Essay.md"). If provided and differs from the active doc, writes directly to disk without switching the user\'s view. Recommended — prevents race conditions when the user navigates during content generation.'),
250
+ docId: z.string().optional().describe('Target document by docId (8-char hex from create_document or list_documents). If provided and differs from the active doc, writes directly to disk without switching the user\'s view. Recommended — prevents race conditions when the user navigates during content generation.'),
211
251
  },
212
- handler: async ({ content, filename }) => {
252
+ handler: async ({ content, docId }) => {
253
+ const filename = docId ? resolveDocId(docId) : undefined;
213
254
  try {
214
255
  let doc;
215
256
  if (typeof content === 'string') {
216
- doc = { type: 'doc', content: parseMarkdownContent(content) };
257
+ let nodes = parseMarkdownContent(content);
258
+ if (isTweetDoc(filename))
259
+ nodes = mergeParagraphsToHardBreaks(nodes);
260
+ doc = { type: 'doc', content: nodes };
217
261
  }
218
262
  else if (content?.type === 'doc' && Array.isArray(content.content)) {
219
263
  doc = content;
@@ -275,17 +319,19 @@ export const TOOL_REGISTRY = [
275
319
  handler: async ({ path }) => {
276
320
  const result = openFile(path);
277
321
  broadcastDocumentSwitched(result.document, result.title, result.filename);
278
- const compact = toCompactFormat(result.document, result.title, getWordCount(), getPendingChangeCount());
279
- return { content: [{ type: 'text', text: `Opened "${result.title}" from ${path}\n\n${compact}` }] };
322
+ const openedDocId = getDocId();
323
+ const compact = toCompactFormat(result.document, result.title, getWordCount(), getPendingChangeCount(), openedDocId);
324
+ return { content: [{ type: 'text', text: `Opened "${result.title}" [${openedDocId}] from ${path}\n\n${compact}` }] };
280
325
  },
281
326
  },
282
327
  {
283
328
  name: 'delete_document',
284
- description: 'Delete a document file. Moves to OS trash (Recycle Bin / macOS Trash). If deleting the active document, automatically switches to the most recent remaining doc. Cannot delete the last document. IMPORTANT: Always confirm with the user before calling this tool.',
329
+ description: 'Delete a document file. Moves to OS trash (Recycle Bin / macOS Trash). If deleting the active document, automatically switches to the most recent remaining doc. Cannot delete the last document. IMPORTANT: Always confirm with the user before calling this tool. Target document by docId (8-char hex from list_documents or read_pad).',
285
330
  schema: {
286
- filename: z.string().describe('Filename of the document to delete (e.g. "My Essay.md")'),
331
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
287
332
  },
288
- handler: async ({ filename }) => {
333
+ handler: async ({ docId }) => {
334
+ const filename = resolveDocId(docId);
289
335
  const result = await deleteDocument(filename);
290
336
  if (result.switched && result.newDoc) {
291
337
  broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
@@ -293,11 +339,44 @@ export const TOOL_REGISTRY = [
293
339
  broadcastDocumentsChanged();
294
340
  let text = `Deleted "${filename}" (moved to trash)`;
295
341
  if (result.switched && result.newDoc) {
296
- text += `. Switched to "${result.newDoc.filename}"`;
342
+ text += `. Switched to "${result.newDoc.title}"`;
343
+ }
344
+ return { content: [{ type: 'text', text }] };
345
+ },
346
+ },
347
+ {
348
+ name: 'archive_document',
349
+ description: 'Archive a document. Removes it from the active document list without deleting the file. Archived docs can be restored later with unarchive_document. If archiving the active document, automatically switches to the most recent remaining doc. Target document by docId (8-char hex from list_documents).',
350
+ schema: {
351
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
352
+ },
353
+ handler: async ({ docId }) => {
354
+ const filename = resolveDocId(docId);
355
+ const result = archiveDocument(filename);
356
+ if (result.switched && result.newDoc) {
357
+ broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
358
+ }
359
+ broadcastDocumentsChanged();
360
+ let text = `Archived "${filename}"`;
361
+ if (result.switched && result.newDoc) {
362
+ text += `. Switched to "${result.newDoc.title}"`;
297
363
  }
298
364
  return { content: [{ type: 'text', text }] };
299
365
  },
300
366
  },
367
+ {
368
+ name: 'unarchive_document',
369
+ description: 'Restore an archived document back to the active document list. Target document by docId (8-char hex from list_documents with includeArchived).',
370
+ schema: {
371
+ docId: z.string().describe('Target document by docId (8-char hex).'),
372
+ },
373
+ handler: async ({ docId }) => {
374
+ const filename = resolveDocId(docId);
375
+ const result = unarchiveDocument(filename);
376
+ broadcastDocumentsChanged();
377
+ return { content: [{ type: 'text', text: `Restored "${result.title}" [${docId}] from archive` }] };
378
+ },
379
+ },
301
380
  {
302
381
  name: 'get_metadata',
303
382
  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.',
@@ -335,8 +414,13 @@ export const TOOL_REGISTRY = [
335
414
  save();
336
415
  broadcastMetadataChanged(getMetadata());
337
416
  if (cleaned.title) {
417
+ // Promote temp file → named file when title is set
418
+ const promoted = promoteTempFile(cleaned.title);
338
419
  broadcastTitleChanged(cleaned.title);
339
420
  broadcastDocumentsChanged();
421
+ if (promoted) {
422
+ broadcastDocumentSwitched(getDocument(), getTitle(), promoted, getMetadata());
423
+ }
340
424
  }
341
425
  const keys = Object.keys(cleaned);
342
426
  const parts = [];
@@ -520,22 +604,27 @@ export const TOOL_REGISTRY = [
520
604
  },
521
605
  {
522
606
  name: 'rename_item',
523
- description: 'Rename a workspace, container, or document. For workspaces: updates the manifest title. For containers: updates the container name in the workspace tree. For documents: updates the title in frontmatter.',
607
+ description: 'Rename a workspace, container, or document. For workspaces: updates the manifest title. For containers: updates the container name in the workspace tree. For documents: updates the title in frontmatter — use docId to identify the document.',
524
608
  schema: {
525
609
  type: z.enum(['workspace', 'container', 'document']).describe('What to rename'),
526
- filename: z.string().describe('Workspace manifest filename (for workspace/container) or document filename (for document)'),
610
+ filename: z.string().optional().describe('Workspace manifest filename (required for workspace/container renames). Not used for document renames.'),
611
+ docId: z.string().optional().describe('Document docId (required for document renames, 8-char hex from list_documents).'),
527
612
  newName: z.string().describe('The new name/title'),
528
613
  containerId: z.string().optional().describe('Container ID (required for container renames)'),
529
614
  workspaceFile: z.string().optional().describe('Parent workspace filename (required for container renames)'),
530
615
  },
531
- handler: async ({ type, filename, newName, containerId, workspaceFile }) => {
616
+ handler: async ({ type, filename, docId, newName, containerId, workspaceFile }) => {
532
617
  if (type === 'workspace') {
618
+ if (!filename)
619
+ return { content: [{ type: 'text', text: 'Error: filename is required for workspace renames' }] };
533
620
  renameWorkspace(filename, newName);
534
621
  broadcastWorkspacesChanged();
535
622
  return { content: [{ type: 'text', text: `Renamed workspace to "${newName}"` }] };
536
623
  }
537
624
  if (type === 'container') {
538
625
  const wsFile = workspaceFile || filename;
626
+ if (!wsFile)
627
+ return { content: [{ type: 'text', text: 'Error: workspaceFile or filename is required for container renames' }] };
539
628
  if (!containerId)
540
629
  return { content: [{ type: 'text', text: 'Error: containerId is required for container renames' }] };
541
630
  renameContainer(wsFile, containerId, newName);
@@ -543,19 +632,22 @@ export const TOOL_REGISTRY = [
543
632
  return { content: [{ type: 'text', text: `Renamed container ${containerId} to "${newName}"` }] };
544
633
  }
545
634
  if (type === 'document') {
546
- updateDocumentTitle(filename, newName);
635
+ if (!docId)
636
+ return { content: [{ type: 'text', text: 'Error: docId is required for document renames' }] };
637
+ const resolvedFilename = resolveDocId(docId);
638
+ updateDocumentTitle(resolvedFilename, newName);
547
639
  broadcastDocumentsChanged();
548
- if (filename === getActiveFilename()) {
640
+ if (resolvedFilename === getActiveFilename()) {
549
641
  broadcastTitleChanged(newName);
550
642
  }
551
- return { content: [{ type: 'text', text: `Renamed document "${filename}" to "${newName}"` }] };
643
+ return { content: [{ type: 'text', text: `Renamed document [${docId}] to "${newName}"` }] };
552
644
  }
553
645
  return { content: [{ type: 'text', text: `Error: unknown type "${type}"` }] };
554
646
  },
555
647
  },
556
648
  {
557
649
  name: 'edit_text',
558
- description: 'Apply fine-grained text edits within a node. Find text by exact match and replace it, or add/remove marks on matched text. More precise than rewriting the whole node. Always specify filename edits target that file directly without switching the user\'s view.',
650
+ description: 'Apply fine-grained text edits within a node. Find text by exact match and replace it, or add/remove marks on matched text. More precise than rewriting the whole node. Target document by docId (8-char hex from list_documents or read_pad).',
559
651
  schema: {
560
652
  nodeId: z.string().describe('ID of the node to edit'),
561
653
  edits: z.array(z.object({
@@ -567,9 +659,10 @@ export const TOOL_REGISTRY = [
567
659
  }).optional().describe('Mark to add to the matched text (e.g. link, bold)'),
568
660
  removeMark: z.string().optional().describe('Mark type to remove from matched text'),
569
661
  })).describe('Array of text edits to apply'),
570
- filename: z.string().describe('Target filename (e.g. "My Essay.md"). Required identifies which document to edit.'),
662
+ docId: z.string().describe('Target document by docId (8-char hex from list_documents or read_pad).'),
571
663
  },
572
- handler: async ({ nodeId, edits, filename }) => {
664
+ handler: async ({ nodeId, edits, docId }) => {
665
+ const filename = resolveDocId(docId);
573
666
  const targetIsNonActive = filename && filename !== getActiveFilename();
574
667
  if (targetIsNonActive) {
575
668
  const result = applyTextEditsToFile(filename, nodeId, edits);
@@ -612,6 +705,11 @@ export const TOOL_REGISTRY = [
612
705
  if (!apiKey) {
613
706
  return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
614
707
  }
708
+ // Capture document context BEFORE the async image generation.
709
+ // The active document can change during the await (user switches docs),
710
+ // so we snapshot the metadata and filePath now to stay scoped.
711
+ const preAwaitFilePath = getFilePath();
712
+ const preAwaitMeta = structuredClone(getMetadata());
615
713
  const { GoogleGenAI } = await import('@google/genai');
616
714
  const ai = new GoogleGenAI({ apiKey });
617
715
  const response = await ai.models.generateContent({
@@ -637,9 +735,20 @@ export const TOOL_REGISTRY = [
637
735
  const src = `/_images/${filename}`;
638
736
  // Optionally set as article cover + append to carousel history
639
737
  if (set_cover) {
640
- const meta = getMetadata();
641
- const articleContext = meta.articleContext || {};
642
- let existing = Array.isArray(articleContext.coverImages) ? articleContext.coverImages : [];
738
+ const docChanged = getFilePath() !== preAwaitFilePath;
739
+ if (docChanged) {
740
+ // Active document changed during image generation — skip set_cover
741
+ // to avoid leaking cover images across documents.
742
+ return {
743
+ content: [{
744
+ type: 'text',
745
+ text: JSON.stringify({ success: true, src, coverSet: false, warning: 'Active document changed during generation — cover not set. Use set_metadata to assign manually.' }),
746
+ }],
747
+ };
748
+ }
749
+ // Use pre-await metadata snapshot to build the update (not live state)
750
+ const articleContext = preAwaitMeta.articleContext || {};
751
+ let existing = Array.isArray(articleContext.coverImages) ? [...articleContext.coverImages] : [];
643
752
  // Seed with current coverImage if array is empty (first carousel entry)
644
753
  if (existing.length === 0 && articleContext.coverImage) {
645
754
  existing = [articleContext.coverImage];
@@ -659,6 +768,69 @@ export const TOOL_REGISTRY = [
659
768
  };
660
769
  },
661
770
  },
771
+ {
772
+ name: 'insert_image',
773
+ 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.',
774
+ schema: {
775
+ docId: z.string().describe('Target document by docId (8-char hex).'),
776
+ prompt: z.string().max(1000).describe('Gemini image generation prompt (max 1000 chars).'),
777
+ afterNodeId: z.string().describe('Insert after this node ID, or "end" to append at the bottom.'),
778
+ aspect_ratio: z.string().optional().describe('Aspect ratio (default "16:9"). Supported: 1:1, 9:16, 16:9, 4:3, 3:4.'),
779
+ alt: z.string().optional().describe('Alt text for the image (defaults to prompt).'),
780
+ },
781
+ handler: async ({ docId, prompt, afterNodeId, aspect_ratio, alt }) => {
782
+ const apiKey = process.env.GEMINI_API_KEY;
783
+ if (!apiKey) {
784
+ return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
785
+ }
786
+ const filename = resolveDocId(docId);
787
+ // Generate image via Gemini
788
+ const { GoogleGenAI } = await import('@google/genai');
789
+ const ai = new GoogleGenAI({ apiKey });
790
+ const response = await ai.models.generateContent({
791
+ model: 'gemini-3.1-flash-image-preview',
792
+ contents: `Generate a ${aspect_ratio || '16:9'} aspect ratio image: ${prompt}`,
793
+ config: {
794
+ responseModalities: ['IMAGE'],
795
+ },
796
+ });
797
+ const parts = response.candidates?.[0]?.content?.parts;
798
+ const imagePart = parts?.find((p) => p.inlineData);
799
+ if (!imagePart?.inlineData?.data) {
800
+ return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
801
+ }
802
+ // Save to ~/.openwriter/_images/
803
+ ensureDataDir();
804
+ const imagesDir = join(DATA_DIR, '_images');
805
+ if (!existsSync(imagesDir))
806
+ mkdirSync(imagesDir, { recursive: true });
807
+ const imgFilename = `${randomUUID().slice(0, 8)}.png`;
808
+ const filePath = join(imagesDir, imgFilename);
809
+ writeFileSync(filePath, Buffer.from(imagePart.inlineData.data, 'base64'));
810
+ const src = `/_images/${imgFilename}`;
811
+ // Build image node and insert change
812
+ const imageNode = { type: 'image', attrs: { src, alt: alt || prompt } };
813
+ const change = { operation: 'insert', afterNodeId, content: [imageNode] };
814
+ const targetIsNonActive = filename && filename !== getActiveFilename();
815
+ if (targetIsNonActive) {
816
+ const { lastNodeId } = applyChangesToFile(filename, [change]);
817
+ broadcastPendingDocsChanged();
818
+ return {
819
+ content: [{
820
+ type: 'text',
821
+ text: JSON.stringify({ success: true, src, ...(lastNodeId ? { lastNodeId } : {}) }),
822
+ }],
823
+ };
824
+ }
825
+ const { lastNodeId } = applyChanges([change]);
826
+ return {
827
+ content: [{
828
+ type: 'text',
829
+ text: JSON.stringify({ success: true, src, ...(lastNodeId ? { lastNodeId } : {}) }),
830
+ }],
831
+ };
832
+ },
833
+ },
662
834
  {
663
835
  name: 'list_versions',
664
836
  description: 'List version history for the active document. Returns timestamps, word counts, and sizes. Use to find a timestamp for restore_version.',
@@ -732,6 +904,51 @@ export const TOOL_REGISTRY = [
732
904
  return { content: [{ type: 'text', text: `Reloaded "${parsed.title}" from disk` }] };
733
905
  },
734
906
  },
907
+ {
908
+ name: 'get_agent_marks',
909
+ description: 'Get inline feedback marks left by the user. Users select text in the editor, right-click → Agent Mark, and leave notes for the agent. Returns marks grouped by document with text, note, and nodeId. Call resolve_agent_marks after addressing each mark.',
910
+ schema: {
911
+ docId: z.string().optional().describe('Target document by docId (8-char hex). Omit to get marks across all documents.'),
912
+ },
913
+ handler: async ({ docId }) => {
914
+ const filename = docId ? resolveDocId(docId) : undefined;
915
+ const marks = getMarks(filename);
916
+ const entries = Object.entries(marks);
917
+ if (entries.length === 0) {
918
+ return { content: [{ type: 'text', text: 'No agent marks found.' }] };
919
+ }
920
+ const lines = [];
921
+ for (const [file, fileMarks] of entries) {
922
+ lines.push(`${file}:`);
923
+ for (const m of fileMarks) {
924
+ const notePart = m.note ? ` — "${m.note}"` : '';
925
+ lines.push(` [${m.id}] "${m.text}"${notePart} (node:${m.nodeId})`);
926
+ }
927
+ }
928
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
929
+ },
930
+ },
931
+ {
932
+ name: 'resolve_agent_marks',
933
+ description: 'Remove agent marks after addressing the user\'s feedback. Pass the mark IDs from get_agent_marks. Decorations clear in the browser immediately.',
934
+ schema: {
935
+ mark_ids: z.array(z.string()).describe('Array of mark IDs to resolve'),
936
+ },
937
+ handler: async ({ mark_ids }) => {
938
+ const resolved = resolveMarks(mark_ids);
939
+ // Broadcast to browser so decorations update
940
+ const activeFile = getActiveFilename();
941
+ broadcastMarksChanged(activeFile);
942
+ return {
943
+ content: [{
944
+ type: 'text',
945
+ text: resolved.length > 0
946
+ ? `Resolved ${resolved.length} mark${resolved.length !== 1 ? 's' : ''}: ${resolved.join(', ')}`
947
+ : 'No matching marks found.',
948
+ }],
949
+ };
950
+ },
951
+ },
735
952
  ];
736
953
  /** Register MCP tools from plugins. Tools added after startMcpServer() won't be visible to existing MCP sessions. */
737
954
  export function registerPluginTools(tools) {
@@ -82,6 +82,9 @@ export function getTitle() {
82
82
  export function getFilePath() {
83
83
  return state.filePath;
84
84
  }
85
+ export function getIsTemp() {
86
+ return state.isTemp;
87
+ }
85
88
  export function getDocId() {
86
89
  return state.docId;
87
90
  }
@@ -143,7 +146,7 @@ export function setMetadata(updates) {
143
146
  state.metadata = { ...state.metadata, ...updates };
144
147
  if (updates.title)
145
148
  state.title = updates.title;
146
- // Auto-tag: tweetContext / articleContext ↔ "x" tag
149
+ // Auto-tag: tweetContext / articleContext ↔ "x" + mode tag
147
150
  for (const key of ['tweetContext', 'articleContext']) {
148
151
  if (key in updates) {
149
152
  const filename = state.filePath
@@ -152,6 +155,9 @@ export function setMetadata(updates) {
152
155
  if (filename) {
153
156
  if (updates[key]) {
154
157
  addDocTag(filename, 'x');
158
+ const mode = updates[key]?.mode || (key === 'articleContext' ? 'article' : undefined);
159
+ if (mode)
160
+ addDocTag(filename, mode);
155
161
  }
156
162
  else {
157
163
  removeDocTag(filename, 'x');
@@ -315,6 +315,22 @@ export function findOrCreateContainer(wsFile, name) {
315
315
  // ============================================================================
316
316
  // CROSS-WORKSPACE QUERIES
317
317
  // ============================================================================
318
+ /** Rename a document reference in every workspace that contains it. */
319
+ export function renameDocInAllWorkspaces(oldFile, newFile, newTitle) {
320
+ const workspaces = listWorkspaces();
321
+ for (const info of workspaces) {
322
+ try {
323
+ const ws = readWorkspace(info.filename);
324
+ const found = findDocNode(ws.root, oldFile);
325
+ if (found) {
326
+ found.node.file = newFile;
327
+ found.node.title = newTitle;
328
+ writeWorkspace(info.filename, ws);
329
+ }
330
+ }
331
+ catch { /* skip corrupt manifests */ }
332
+ }
333
+ }
318
334
  /** Remove a document from every workspace that references it. */
319
335
  export function removeDocFromAllWorkspaces(file) {
320
336
  const workspaces = listWorkspaces();
package/dist/server/ws.js CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { WebSocketServer, WebSocket } from 'ws';
5
5
  import { updateDocument, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, onChanges, isAgentLocked, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, } from './state.js';
6
- import { switchDocument, createDocument, deleteDocument, getActiveFilename } from './documents.js';
6
+ import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile } from './documents.js';
7
7
  import { removeDocFromAllWorkspaces } from './workspaces.js';
8
8
  const clients = new Set();
9
9
  let currentAgentConnected = false;
@@ -114,8 +114,16 @@ export function setupWebSocket(server) {
114
114
  }
115
115
  if (msg.type === 'title-update' && msg.title) {
116
116
  setMetadata({ title: msg.title });
117
- debouncedSave();
118
- debouncedBroadcastDocumentsChanged();
117
+ const promoted = promoteTempFile(msg.title);
118
+ if (promoted) {
119
+ save();
120
+ broadcastDocumentSwitched(getDocument(), getTitle(), promoted, getMetadata());
121
+ broadcastDocumentsChanged();
122
+ }
123
+ else {
124
+ debouncedSave();
125
+ debouncedBroadcastDocumentsChanged();
126
+ }
119
127
  }
120
128
  if (msg.type === 'save') {
121
129
  save();
@@ -331,6 +339,13 @@ export function broadcastWritingFinished() {
331
339
  ws.send(msg);
332
340
  }
333
341
  }
342
+ export function broadcastMarksChanged(filename) {
343
+ const msg = JSON.stringify({ type: 'marks-changed', filename });
344
+ for (const ws of clients) {
345
+ if (ws.readyState === WebSocket.OPEN)
346
+ ws.send(msg);
347
+ }
348
+ }
334
349
  export function broadcastSyncStatus(status) {
335
350
  lastSyncStatus = status;
336
351
  const msg = JSON.stringify({ type: 'sync-status', ...status });